@specverse/engines 6.42.3 → 6.53.1
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.map +1 -1
- package/dist/ai/analyse-runner.js +53 -1
- package/dist/ai/analyse-runner.js.map +1 -1
- package/dist/ai/prompt-runner.d.ts +39 -1
- package/dist/ai/prompt-runner.d.ts.map +1 -1
- package/dist/ai/prompt-runner.js +44 -3
- package/dist/ai/prompt-runner.js.map +1 -1
- package/dist/ai/providers/claude-cli.d.ts.map +1 -1
- package/dist/ai/providers/claude-cli.js +8 -1
- package/dist/ai/providers/claude-cli.js.map +1 -1
- package/dist/ai/skill-loader.d.ts +50 -0
- package/dist/ai/skill-loader.d.ts.map +1 -0
- package/dist/ai/skill-loader.js +96 -0
- package/dist/ai/skill-loader.js.map +1 -0
- package/dist/analyse-prepass/adapters/index.d.ts +2 -0
- package/dist/analyse-prepass/adapters/index.d.ts.map +1 -1
- package/dist/analyse-prepass/adapters/index.js +2 -0
- package/dist/analyse-prepass/adapters/index.js.map +1 -1
- package/dist/analyse-prepass/adapters/module-functions.d.ts +95 -0
- package/dist/analyse-prepass/adapters/module-functions.d.ts.map +1 -0
- package/dist/analyse-prepass/adapters/module-functions.js +358 -0
- package/dist/analyse-prepass/adapters/module-functions.js.map +1 -0
- package/dist/analyse-prepass/adapters/naming-convention-fks.d.ts +90 -0
- package/dist/analyse-prepass/adapters/naming-convention-fks.d.ts.map +1 -0
- package/dist/analyse-prepass/adapters/naming-convention-fks.js +181 -0
- package/dist/analyse-prepass/adapters/naming-convention-fks.js.map +1 -0
- package/dist/analyse-prepass/index.d.ts +8 -0
- package/dist/analyse-prepass/index.d.ts.map +1 -1
- package/dist/analyse-prepass/index.js +130 -0
- package/dist/analyse-prepass/index.js.map +1 -1
- package/dist/libs/instance-factories/cli/templates/commander/cli-entry-generator.js +11 -12
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +2 -2
- package/dist/libs/instance-factories/services/templates/prisma/behavior-generator.js +24 -2
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +28 -20
- package/dist/normalise/index.d.ts +14 -0
- package/dist/normalise/index.d.ts.map +1 -0
- package/dist/normalise/index.js +14 -0
- package/dist/normalise/index.js.map +1 -0
- package/dist/normalise/load-overrides.d.ts +43 -0
- package/dist/normalise/load-overrides.d.ts.map +1 -0
- package/dist/normalise/load-overrides.js +121 -0
- package/dist/normalise/load-overrides.js.map +1 -0
- package/dist/normalise/normalise-rules.d.ts +181 -0
- package/dist/normalise/normalise-rules.d.ts.map +1 -0
- package/dist/normalise/normalise-rules.js +79 -0
- package/dist/normalise/normalise-rules.js.map +1 -0
- package/dist/normalise/rules/cluster-module-functions.d.ts +31 -0
- package/dist/normalise/rules/cluster-module-functions.d.ts.map +1 -0
- package/dist/normalise/rules/cluster-module-functions.js +238 -0
- package/dist/normalise/rules/cluster-module-functions.js.map +1 -0
- package/dist/normalise/rules/crud-into-curved.d.ts +117 -0
- package/dist/normalise/rules/crud-into-curved.d.ts.map +1 -0
- package/dist/normalise/rules/crud-into-curved.js +303 -0
- package/dist/normalise/rules/crud-into-curved.js.map +1 -0
- package/dist/normalise/rules/drop-trivial-passthrough.d.ts +92 -0
- package/dist/normalise/rules/drop-trivial-passthrough.d.ts.map +1 -0
- package/dist/normalise/rules/drop-trivial-passthrough.js +217 -0
- package/dist/normalise/rules/drop-trivial-passthrough.js.map +1 -0
- package/dist/normalise/runner.d.ts +58 -0
- package/dist/normalise/runner.d.ts.map +1 -0
- package/dist/normalise/runner.js +114 -0
- package/dist/normalise/runner.js.map +1 -0
- package/dist/parser/import-resolver/resolver.js +1 -1
- package/dist/parser/import-resolver/resolver.js.map +1 -1
- package/dist/realize/engines/typescript-engine.js +1 -1
- package/dist/realize/engines/typescript-engine.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +142 -90
- package/dist/realize/index.js.map +1 -1
- package/dist/realize/library/library.js +1 -1
- package/dist/realize/library/library.js.map +1 -1
- package/dist/realize/library/resolver.d.ts.map +1 -1
- package/dist/realize/library/resolver.js +14 -1
- package/dist/realize/library/resolver.js.map +1 -1
- package/dist/realize/owner-emit-shared.d.ts +114 -0
- package/dist/realize/owner-emit-shared.d.ts.map +1 -0
- package/dist/realize/owner-emit-shared.js +227 -0
- package/dist/realize/owner-emit-shared.js.map +1 -0
- package/dist/realize/per-owner-emit.d.ts +1 -58
- package/dist/realize/per-owner-emit.d.ts.map +1 -1
- package/dist/realize/per-owner-emit.js +45 -209
- package/dist/realize/per-owner-emit.js.map +1 -1
- package/dist/realize/per-owner-runner.d.ts +1 -2
- package/dist/realize/per-owner-runner.d.ts.map +1 -1
- package/dist/realize/per-owner-runner.js +1 -1
- package/dist/realize/per-owner-runner.js.map +1 -1
- package/dist/realize/post-emit-verify/feedback-runner.d.ts +84 -0
- package/dist/realize/post-emit-verify/feedback-runner.d.ts.map +1 -0
- package/dist/realize/post-emit-verify/feedback-runner.js +177 -0
- package/dist/realize/post-emit-verify/feedback-runner.js.map +1 -0
- package/dist/realize/post-emit-verify/index.d.ts +17 -0
- package/dist/realize/post-emit-verify/index.d.ts.map +1 -0
- package/dist/realize/post-emit-verify/index.js +16 -0
- package/dist/realize/post-emit-verify/index.js.map +1 -0
- package/dist/realize/post-emit-verify/reemit.d.ts +61 -0
- package/dist/realize/post-emit-verify/reemit.d.ts.map +1 -0
- package/dist/realize/post-emit-verify/reemit.js +122 -0
- package/dist/realize/post-emit-verify/reemit.js.map +1 -0
- package/dist/realize/post-emit-verify/types.d.ts +138 -0
- package/dist/realize/post-emit-verify/types.d.ts.map +1 -0
- package/dist/realize/post-emit-verify/types.js +28 -0
- package/dist/realize/post-emit-verify/types.js.map +1 -0
- package/dist/realize/post-emit-verify/verifier-manifest.d.ts +29 -0
- package/dist/realize/post-emit-verify/verifier-manifest.d.ts.map +1 -0
- package/dist/realize/post-emit-verify/verifier-manifest.js +55 -0
- package/dist/realize/post-emit-verify/verifier-manifest.js.map +1 -0
- package/dist/realize/post-emit-verify/verifiers/tsc.d.ts +24 -0
- package/dist/realize/post-emit-verify/verifiers/tsc.d.ts.map +1 -0
- package/dist/realize/post-emit-verify/verifiers/tsc.js +148 -0
- package/dist/realize/post-emit-verify/verifiers/tsc.js.map +1 -0
- package/dist/realize/realize-rules.d.ts +113 -0
- package/dist/realize/realize-rules.d.ts.map +1 -0
- package/dist/realize/realize-rules.js +271 -0
- package/dist/realize/realize-rules.js.map +1 -0
- package/libs/instance-factories/cli/templates/commander/cli-entry-generator.ts +11 -12
- package/libs/instance-factories/cli/templates/commander/command-generator.ts +2 -2
- package/libs/instance-factories/services/templates/prisma/behavior-generator.ts +62 -2
- package/libs/instance-factories/services/templates/prisma/controller-generator.ts +42 -20
- package/package.json +9 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalise rule: `cluster-module-functions`.
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 of the 2026-05-13-NORMALISE-PHASE proposal. Cleans up
|
|
5
|
+
* project-prefix noise on services synthesised by the module-functions
|
|
6
|
+
* adapter (Phase 0 prepass change).
|
|
7
|
+
*
|
|
8
|
+
* Trigger pattern: PowerSync / Drizzle / Supabase-queries codebases
|
|
9
|
+
* tend to prefix module functions with a project tag:
|
|
10
|
+
*
|
|
11
|
+
* export async function smcCreateRound(...)
|
|
12
|
+
* export async function smcGetRound(...)
|
|
13
|
+
* export async function smcUpdateRoundStatus(...)
|
|
14
|
+
*
|
|
15
|
+
* The prepass synthesises one service per file. After Phase 0 the
|
|
16
|
+
* faithful spec looks like:
|
|
17
|
+
*
|
|
18
|
+
* SmcRoundsService:
|
|
19
|
+
* operations:
|
|
20
|
+
* smcCreateRound: { ... }
|
|
21
|
+
* smcGetRound: { ... }
|
|
22
|
+
* smcUpdateRoundStatus: { ... }
|
|
23
|
+
*
|
|
24
|
+
* After this rule fires:
|
|
25
|
+
*
|
|
26
|
+
* RoundsService:
|
|
27
|
+
* operations:
|
|
28
|
+
* createRound: { ... }
|
|
29
|
+
* getRound: { ... }
|
|
30
|
+
* updateRoundStatus: { ... }
|
|
31
|
+
*
|
|
32
|
+
* The `Smc` / `smc` project prefix appears on both the service name
|
|
33
|
+
* (PascalCase) and operation names (camelCase). Stripping it from both
|
|
34
|
+
* removes noise while preserving the rest of the naming so the LLM-
|
|
35
|
+
* authored bodies still reference the right entities.
|
|
36
|
+
*
|
|
37
|
+
* What this rule does NOT do (deferred to later rules):
|
|
38
|
+
* - Rename service from `RoundsService` to `RoundService` based on
|
|
39
|
+
* detected target entity (would be `cluster-module-functions`'s
|
|
40
|
+
* dominant-entity inference, per proposal §5 — separate rule)
|
|
41
|
+
* - Collapse `RoundsService.getRound` → `Round.retrieve` CURVED
|
|
42
|
+
* (that's `crud-into-curved`, Rule 2)
|
|
43
|
+
* - Drop trivial passthrough ops (Rule 3)
|
|
44
|
+
*
|
|
45
|
+
* Conservative invariants:
|
|
46
|
+
* - Prefix must be 2–3 lowercase letters ending just before an
|
|
47
|
+
* uppercase letter (camelCase boundary). The 3-char ceiling is the
|
|
48
|
+
* load-bearing false-positive defence: real project tags are short
|
|
49
|
+
* non-words (`smc`, `db`, `api`, `iam`); English verbs that share
|
|
50
|
+
* a camelCase head (`save`, `claim`, `format`, `calculate`,
|
|
51
|
+
* `invalidate`) are 4+ characters and stay untouched.
|
|
52
|
+
* - At least ONE operation must share the prefix (no service-name-
|
|
53
|
+
* only renames — `SaveGameService` doesn't get stripped to
|
|
54
|
+
* `GameService` just because `Save` appears at the start of the
|
|
55
|
+
* service name; that's a meaningful word, not a project tag).
|
|
56
|
+
* - Skip when stripping would produce a name collision.
|
|
57
|
+
* - Skip when stripping would produce an empty / invalid name.
|
|
58
|
+
* - Skip when ctx.source === 'hand-authored'.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_PREFIX_LENGTH = 3;
|
|
61
|
+
/**
|
|
62
|
+
* Find the longest lowercase prefix shared by `names`, ending strictly
|
|
63
|
+
* before the first uppercase letter (camelCase boundary). Returns the
|
|
64
|
+
* empty string when no such prefix is found.
|
|
65
|
+
*/
|
|
66
|
+
function findCamelPrefix(names) {
|
|
67
|
+
if (names.length === 0)
|
|
68
|
+
return '';
|
|
69
|
+
const reference = names[0];
|
|
70
|
+
let candidate = '';
|
|
71
|
+
for (let i = 0; i < reference.length; i++) {
|
|
72
|
+
const c = reference[i];
|
|
73
|
+
if (c >= 'A' && c <= 'Z')
|
|
74
|
+
break;
|
|
75
|
+
if (!(c >= 'a' && c <= 'z'))
|
|
76
|
+
break;
|
|
77
|
+
if (!names.every((n) => n[i] === c))
|
|
78
|
+
break;
|
|
79
|
+
candidate += c;
|
|
80
|
+
}
|
|
81
|
+
return candidate;
|
|
82
|
+
}
|
|
83
|
+
function stripCamelPrefix(name, prefix) {
|
|
84
|
+
if (!name.startsWith(prefix))
|
|
85
|
+
return name;
|
|
86
|
+
const rest = name.slice(prefix.length);
|
|
87
|
+
if (rest.length === 0)
|
|
88
|
+
return name;
|
|
89
|
+
return rest[0].toLowerCase() + rest.slice(1);
|
|
90
|
+
}
|
|
91
|
+
function stripPascalPrefix(serviceName, prefix) {
|
|
92
|
+
if (prefix.length === 0)
|
|
93
|
+
return null;
|
|
94
|
+
const pascalPrefix = prefix[0].toUpperCase() + prefix.slice(1).toLowerCase();
|
|
95
|
+
if (!serviceName.startsWith(pascalPrefix))
|
|
96
|
+
return null;
|
|
97
|
+
const rest = serviceName.slice(pascalPrefix.length);
|
|
98
|
+
if (rest.length === 0)
|
|
99
|
+
return null;
|
|
100
|
+
if (!(rest[0] >= 'A' && rest[0] <= 'Z'))
|
|
101
|
+
return null;
|
|
102
|
+
return rest;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Detect whether the service with `serviceName` is a cluster-cleanup
|
|
106
|
+
* candidate. See file-header doc for the two detection signals.
|
|
107
|
+
*/
|
|
108
|
+
function detectClusterMatch(componentName, serviceName, serviceBody) {
|
|
109
|
+
const ops = serviceBody.operations;
|
|
110
|
+
if (!ops || typeof ops !== 'object')
|
|
111
|
+
return null;
|
|
112
|
+
const opNames = Object.keys(ops);
|
|
113
|
+
if (opNames.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
// Signal A — multi-operation common prefix.
|
|
116
|
+
let prefix = '';
|
|
117
|
+
if (opNames.length >= 2) {
|
|
118
|
+
prefix = findCamelPrefix(opNames);
|
|
119
|
+
}
|
|
120
|
+
// Signal B — service-name prefix matched by ≥1 op's camel prefix.
|
|
121
|
+
if ((prefix.length < 2 || prefix.length > MAX_PREFIX_LENGTH) && opNames.length >= 1) {
|
|
122
|
+
for (const op of opNames) {
|
|
123
|
+
const opPrefix = findCamelPrefix([op]);
|
|
124
|
+
if (opPrefix.length < 2 || opPrefix.length > MAX_PREFIX_LENGTH)
|
|
125
|
+
continue;
|
|
126
|
+
if (stripPascalPrefix(serviceName, opPrefix) !== null) {
|
|
127
|
+
prefix = opPrefix;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Length cap: project tags are short. Anything longer is an English
|
|
133
|
+
// verb like `save` / `claim` / `calculate` that we mustn't strip.
|
|
134
|
+
if (prefix.length < 2 || prefix.length > MAX_PREFIX_LENGTH)
|
|
135
|
+
return null;
|
|
136
|
+
// Compute the renames + check for collisions.
|
|
137
|
+
const opRenames = new Map();
|
|
138
|
+
const newNamesSeen = new Set();
|
|
139
|
+
for (const op of opNames) {
|
|
140
|
+
const newName = stripCamelPrefix(op, prefix);
|
|
141
|
+
if (newName === op)
|
|
142
|
+
continue;
|
|
143
|
+
if (newName.length === 0)
|
|
144
|
+
return null;
|
|
145
|
+
if (newNamesSeen.has(newName) || opNames.includes(newName))
|
|
146
|
+
return null;
|
|
147
|
+
opRenames.set(op, newName);
|
|
148
|
+
newNamesSeen.add(newName);
|
|
149
|
+
}
|
|
150
|
+
// Activity requirement: at least one operation must share the prefix.
|
|
151
|
+
// Without this, a service whose NAME happens to begin with a short
|
|
152
|
+
// lowercase word (`SaveGameService`, `IdpFooService`) would get
|
|
153
|
+
// renamed even when no operation evidence supports the prefix being
|
|
154
|
+
// a project tag.
|
|
155
|
+
if (opRenames.size === 0)
|
|
156
|
+
return null;
|
|
157
|
+
const newServiceName = stripPascalPrefix(serviceName, prefix);
|
|
158
|
+
return {
|
|
159
|
+
componentName,
|
|
160
|
+
oldServiceName: serviceName,
|
|
161
|
+
newServiceName: newServiceName ?? serviceName,
|
|
162
|
+
prefix,
|
|
163
|
+
opRenames,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function applyClusterMatch(spec, data) {
|
|
167
|
+
const components = spec.components;
|
|
168
|
+
const component = components?.[data.componentName];
|
|
169
|
+
const services = component?.services;
|
|
170
|
+
if (!services)
|
|
171
|
+
return spec;
|
|
172
|
+
const serviceBody = services[data.oldServiceName];
|
|
173
|
+
if (!serviceBody)
|
|
174
|
+
return spec;
|
|
175
|
+
// Rename operations preserving insertion order.
|
|
176
|
+
const oldOps = serviceBody.operations;
|
|
177
|
+
const renamedOps = {};
|
|
178
|
+
for (const [oldKey, value] of Object.entries(oldOps)) {
|
|
179
|
+
const newKey = data.opRenames.get(oldKey) ?? oldKey;
|
|
180
|
+
renamedOps[newKey] = value;
|
|
181
|
+
}
|
|
182
|
+
serviceBody.operations = renamedOps;
|
|
183
|
+
// Rename service key if changed. We need to preserve insertion order
|
|
184
|
+
// here too — rebuild the services object with the renamed key in the
|
|
185
|
+
// original slot.
|
|
186
|
+
if (data.newServiceName !== data.oldServiceName) {
|
|
187
|
+
const rebuilt = {};
|
|
188
|
+
for (const [k, v] of Object.entries(services)) {
|
|
189
|
+
if (k === data.oldServiceName)
|
|
190
|
+
rebuilt[data.newServiceName] = v;
|
|
191
|
+
else
|
|
192
|
+
rebuilt[k] = v;
|
|
193
|
+
}
|
|
194
|
+
component.services = rebuilt;
|
|
195
|
+
}
|
|
196
|
+
return spec;
|
|
197
|
+
}
|
|
198
|
+
export const CLUSTER_MODULE_FUNCTIONS_RULE = {
|
|
199
|
+
id: 'cluster-module-functions',
|
|
200
|
+
summary: 'Strip project-prefix from synthesised module-function services + their operations (e.g. SmcRoundsService.smcGetRound → RoundsService.getRound).',
|
|
201
|
+
ordering: 0,
|
|
202
|
+
enabledByDefault: true,
|
|
203
|
+
appliesWhen: (ctx) => ctx.source !== 'hand-authored',
|
|
204
|
+
detect: (spec) => {
|
|
205
|
+
const matches = [];
|
|
206
|
+
const components = spec.components;
|
|
207
|
+
if (!components || typeof components !== 'object')
|
|
208
|
+
return matches;
|
|
209
|
+
for (const [componentName, componentBody] of Object.entries(components)) {
|
|
210
|
+
const services = componentBody?.services;
|
|
211
|
+
if (!services || typeof services !== 'object')
|
|
212
|
+
continue;
|
|
213
|
+
for (const [serviceName, serviceBody] of Object.entries(services)) {
|
|
214
|
+
if (!serviceBody || typeof serviceBody !== 'object')
|
|
215
|
+
continue;
|
|
216
|
+
const data = detectClusterMatch(componentName, serviceName, serviceBody);
|
|
217
|
+
if (!data)
|
|
218
|
+
continue;
|
|
219
|
+
matches.push({
|
|
220
|
+
description: `${componentName}.${serviceName}: strip prefix "${data.prefix}" — ${data.opRenames.size} operation(s) renamed${data.newServiceName !== serviceName ? `, service → ${data.newServiceName}` : ''}`,
|
|
221
|
+
data,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return matches;
|
|
226
|
+
},
|
|
227
|
+
transform: (spec, match) => {
|
|
228
|
+
return applyClusterMatch(spec, match.data);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
// Internal helpers exported for unit testing.
|
|
232
|
+
export const __internal = {
|
|
233
|
+
findCamelPrefix,
|
|
234
|
+
stripCamelPrefix,
|
|
235
|
+
stripPascalPrefix,
|
|
236
|
+
detectClusterMatch,
|
|
237
|
+
};
|
|
238
|
+
//# sourceMappingURL=cluster-module-functions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cluster-module-functions.js","sourceRoot":"","sources":["../../../src/normalise/rules/cluster-module-functions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AACH,MAAM,iBAAiB,GAAG,CAAC,CAAC;AAkB5B;;;;GAIG;AACH,SAAS,eAAe,CAAC,KAAe;IACtC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAClC,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;IAC5B,IAAI,SAAS,GAAG,EAAE,CAAC;IACnB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAC1C,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC,CAAE,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,GAAG;YAAE,MAAM;QAChC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,GAAG,CAAC;YAAE,MAAM;QACnC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAAE,MAAM;QAC3C,SAAS,IAAI,CAAC,CAAC;IACjB,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAY,EAAE,MAAc;IACpD,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1C,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACvC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,OAAO,IAAI,CAAC,CAAC,CAAE,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAChD,CAAC;AAED,SAAS,iBAAiB,CAAC,WAAmB,EAAE,MAAc;IAC5D,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,MAAM,YAAY,GAAG,MAAM,CAAC,CAAC,CAAE,CAAC,WAAW,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAC9E,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,YAAY,CAAC;QAAE,OAAO,IAAI,CAAC;IACvD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IACpD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAE,IAAI,GAAG,IAAI,IAAI,CAAC,CAAC,CAAE,IAAI,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACvD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,SAAS,kBAAkB,CACzB,aAAqB,EACrB,WAAmB,EACnB,WAAoC;IAEpC,MAAM,GAAG,GAAG,WAAW,CAAC,UAAiD,CAAC;IAC1E,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACjD,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACjC,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEtC,4CAA4C;IAC5C,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;QACxB,MAAM,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;IACpC,CAAC;IAED,kEAAkE;IAClE,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,MAAM,GAAG,iBAAiB,CAAC,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;QACpF,KAAK,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;YACzB,MAAM,QAAQ,GAAG,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACvC,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,MAAM,GAAG,iBAAiB;gBAAE,SAAS;YACzE,IAAI,iBAAiB,CAAC,WAAW,EAAE,QAAQ,CAAC,KAAK,IAAI,EAAE,CAAC;gBACtD,MAAM,GAAG,QAAQ,CAAC;gBAClB,MAAM;YACR,CAAC;QACH,CAAC;IACH,CAAC;IAED,oEAAoE;IACpE,kEAAkE;IAClE,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,MAAM,GAAG,iBAAiB;QAAE,OAAO,IAAI,CAAC;IAExE,8CAA8C;IAC9C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC5C,MAAM,YAAY,GAAG,IAAI,GAAG,EAAU,CAAC;IACvC,KAAK,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;QACzB,MAAM,OAAO,GAAG,gBAAgB,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QAC7C,IAAI,OAAO,KAAK,EAAE;YAAE,SAAS;QAC7B,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACtC,IAAI,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QACxE,SAAS,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;QAC3B,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC5B,CAAC;IAED,sEAAsE;IACtE,mEAAmE;IACnE,gEAAgE;IAChE,oEAAoE;IACpE,iBAAiB;IACjB,IAAI,SAAS,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEtC,MAAM,cAAc,GAAG,iBAAiB,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IAE9D,OAAO;QACL,aAAa;QACb,cAAc,EAAE,WAAW;QAC3B,cAAc,EAAE,cAAc,IAAI,WAAW;QAC7C,MAAM;QACN,SAAS;KACV,CAAC;AACJ,CAAC;AAED,SAAS,iBAAiB,CAAC,IAAmB,EAAE,IAAsB;IACpE,MAAM,UAAU,GAAG,IAAI,CAAC,UAAiD,CAAC;IAC1E,MAAM,SAAS,GAAG,UAAU,EAAE,CAAC,IAAI,CAAC,aAAa,CAAwC,CAAC;IAC1F,MAAM,QAAQ,GAAG,SAAS,EAAE,QAA+C,CAAC;IAC5E,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3B,MAAM,WAAW,GAAG,QAAQ,CAAC,IAAI,CAAC,cAAc,CAAwC,CAAC;IACzF,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAC;IAE9B,gDAAgD;IAChD,MAAM,MAAM,GAAG,WAAW,CAAC,UAAqC,CAAC;IACjE,MAAM,UAAU,GAA4B,EAAE,CAAC;IAC/C,KAAK,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACrD,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC;QACpD,UAAU,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC;IAC7B,CAAC;IACD,WAAW,CAAC,UAAU,GAAG,UAAU,CAAC;IAEpC,qEAAqE;IACrE,qEAAqE;IACrE,iBAAiB;IACjB,IAAI,IAAI,CAAC,cAAc,KAAK,IAAI,CAAC,cAAc,EAAE,CAAC;QAChD,MAAM,OAAO,GAA4B,EAAE,CAAC;QAC5C,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC9C,IAAI,CAAC,KAAK,IAAI,CAAC,cAAc;gBAAE,OAAO,CAAC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC;;gBAC3D,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACtB,CAAC;QACD,SAAU,CAAC,QAAQ,GAAG,OAAO,CAAC;IAChC,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,MAAM,6BAA6B,GAAkB;IAC1D,EAAE,EAAE,0BAA0B;IAC9B,OAAO,EACL,iJAAiJ;IACnJ,QAAQ,EAAE,CAAC;IACX,gBAAgB,EAAE,IAAI;IACtB,WAAW,EAAE,CAAC,GAAqB,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,KAAK,eAAe;IAEtE,MAAM,EAAE,CAAC,IAAmB,EAAwB,EAAE;QACpD,MAAM,OAAO,GAAyB,EAAE,CAAC;QACzC,MAAM,UAAU,GAAG,IAAI,CAAC,UAAiD,CAAC;QAC1E,IAAI,CAAC,UAAU,IAAI,OAAO,UAAU,KAAK,QAAQ;YAAE,OAAO,OAAO,CAAC;QAClE,KAAK,MAAM,CAAC,aAAa,EAAE,aAAa,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC;YACxE,MAAM,QAAQ,GAAI,aAAyC,EAAE,QAEhD,CAAC;YACd,IAAI,CAAC,QAAQ,IAAI,OAAO,QAAQ,KAAK,QAAQ;gBAAE,SAAS;YACxD,KAAK,MAAM,CAAC,WAAW,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAClE,IAAI,CAAC,WAAW,IAAI,OAAO,WAAW,KAAK,QAAQ;oBAAE,SAAS;gBAC9D,MAAM,IAAI,GAAG,kBAAkB,CAC7B,aAAa,EACb,WAAW,EACX,WAAsC,CACvC,CAAC;gBACF,IAAI,CAAC,IAAI;oBAAE,SAAS;gBACpB,OAAO,CAAC,IAAI,CAAC;oBACX,WAAW,EAAE,GAAG,aAAa,IAAI,WAAW,mBAAmB,IAAI,CAAC,MAAM,OAAO,IAAI,CAAC,SAAS,CAAC,IAAI,wBAClG,IAAI,CAAC,cAAc,KAAK,WAAW,CAAC,CAAC,CAAC,eAAe,IAAI,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAC/E,EAAE;oBACF,IAAI;iBACL,CAAC,CAAC;YACL,CAAC;QACH,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,SAAS,EAAE,CAAC,IAAmB,EAAE,KAAyB,EAAiB,EAAE;QAC3E,OAAO,iBAAiB,CAAC,IAAI,EAAE,KAAK,CAAC,IAAwB,CAAC,CAAC;IACjE,CAAC;CACF,CAAC;AAEF,8CAA8C;AAC9C,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,eAAe;IACf,gBAAgB;IAChB,iBAAiB;IACjB,kBAAkB;CACnB,CAAC"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalise rule: `crud-into-curved`.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3 of `2026-05-13-NORMALISE-PHASE.md` §5 Rule 2. Lifts Service
|
|
5
|
+
* operations that match the canonical CRUD shape onto their target
|
|
6
|
+
* entity's Controller `cured:` block. This eliminates redundant service
|
|
7
|
+
* shells for plain CRUD wrappers — SpecVerse generates
|
|
8
|
+
* `cured.retrieve/create/update/delete` from a controller's cured
|
|
9
|
+
* declaration, so an explicit `Service.getClub` that just does
|
|
10
|
+
* "look up by id" is duplicating convention.
|
|
11
|
+
*
|
|
12
|
+
* Schema note: CURVED operations live on Controllers, NOT on Models.
|
|
13
|
+
* If the target entity has no Controller declared, the rule synthesises
|
|
14
|
+
* a `<Entity>Controller` (with `model: <Entity>`) in the same component
|
|
15
|
+
* as the Model. The cured op lands there.
|
|
16
|
+
*
|
|
17
|
+
* Before (post-Rule-1 / cluster-module-functions):
|
|
18
|
+
*
|
|
19
|
+
* components:
|
|
20
|
+
* App:
|
|
21
|
+
* models:
|
|
22
|
+
* Club:
|
|
23
|
+
* attributes: [...]
|
|
24
|
+
* services:
|
|
25
|
+
* ClubsService:
|
|
26
|
+
* operations:
|
|
27
|
+
* getClub:
|
|
28
|
+
* steps: [Look up Club by id, Return Club or null]
|
|
29
|
+
* requires: [id is provided]
|
|
30
|
+
* getClubPositions: # NOT lifted (filtered query)
|
|
31
|
+
* steps: [...]
|
|
32
|
+
*
|
|
33
|
+
* After:
|
|
34
|
+
*
|
|
35
|
+
* components:
|
|
36
|
+
* App:
|
|
37
|
+
* models:
|
|
38
|
+
* Club:
|
|
39
|
+
* attributes: [...]
|
|
40
|
+
* controllers:
|
|
41
|
+
* ClubController: # synthesised by this rule
|
|
42
|
+
* model: Club
|
|
43
|
+
* cured:
|
|
44
|
+
* retrieve:
|
|
45
|
+
* steps: [Look up Club by id, Return Club or null]
|
|
46
|
+
* requires: [id is provided]
|
|
47
|
+
* services:
|
|
48
|
+
* ClubsService:
|
|
49
|
+
* operations:
|
|
50
|
+
* getClubPositions: { ... } # remains
|
|
51
|
+
*
|
|
52
|
+
* If the service becomes empty after lifting, it is dropped entirely.
|
|
53
|
+
*
|
|
54
|
+
* Conservative matching:
|
|
55
|
+
* - Op name must be EXACTLY `<verb><EntityName>` — no trailing suffix
|
|
56
|
+
* (`getRound` ✓, `getRoundsForUser` ✗, `getRoundStatus` ✗)
|
|
57
|
+
* - Target Entity name must resolve UNIQUELY to ONE model across all
|
|
58
|
+
* components (the cured block goes to whichever component the model
|
|
59
|
+
* lives in — cross-component lifts are fine since the cured: lives
|
|
60
|
+
* ON the model, not at the service site)
|
|
61
|
+
* - Skip when the entity name is declared in multiple components (rare,
|
|
62
|
+
* but ambiguous — caller can rename the duplicate)
|
|
63
|
+
* - Target model's `cured.<curvedOp>` must NOT already be declared
|
|
64
|
+
* (don't overwrite spec-author intent)
|
|
65
|
+
* - Verb patterns recognised:
|
|
66
|
+
* get / retrieve / find → retrieve
|
|
67
|
+
* create / add → create (Phase 3: only `create` to stay safe)
|
|
68
|
+
* update → update
|
|
69
|
+
* delete / remove → delete (Phase 3: only `delete` to stay safe)
|
|
70
|
+
*
|
|
71
|
+
* NOT in Phase 3 scope:
|
|
72
|
+
* - retrieve_many (list / getAll / search — naming varies too widely)
|
|
73
|
+
* - Evolve transitions (would need lifecycle state-machine signal)
|
|
74
|
+
* - Cross-component lifts (target model in a different component)
|
|
75
|
+
* - Multi-arg create variants (createClubWithPositions)
|
|
76
|
+
*/
|
|
77
|
+
import type { NormaliseRule, NormaliseSpec } from '../normalise-rules.js';
|
|
78
|
+
interface CrudMatchData {
|
|
79
|
+
/** Component hosting the source Service. */
|
|
80
|
+
sourceComponentName: string;
|
|
81
|
+
/** Component hosting the target Model (may differ from source). */
|
|
82
|
+
targetComponentName: string;
|
|
83
|
+
modelName: string;
|
|
84
|
+
serviceName: string;
|
|
85
|
+
operationName: string;
|
|
86
|
+
curvedOp: 'retrieve' | 'create' | 'update' | 'delete';
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse an operation name into (verb, entityName) if it matches the
|
|
90
|
+
* exact `<verb><PascalCaseEntity>` shape. Returns null otherwise.
|
|
91
|
+
*
|
|
92
|
+
* The entity portion must be PascalCase, contain no further uppercase
|
|
93
|
+
* boundaries after the leading letter (i.e. single PascalCase word),
|
|
94
|
+
* AND must end at the end of the string (no trailing qualifier like
|
|
95
|
+
* `getRoundStatus` or `getClubsForUser`).
|
|
96
|
+
*/
|
|
97
|
+
declare function parseCrudOpName(opName: string): {
|
|
98
|
+
verb: string;
|
|
99
|
+
curved: CrudMatchData['curvedOp'];
|
|
100
|
+
entity: string;
|
|
101
|
+
} | null;
|
|
102
|
+
/**
|
|
103
|
+
* Build a model-name → component-name index across all components. Used
|
|
104
|
+
* by the cross-component target lookup. When an entity name is declared
|
|
105
|
+
* in multiple components, it's recorded as ambiguous (the rule then
|
|
106
|
+
* skips matches against that name).
|
|
107
|
+
*/
|
|
108
|
+
declare function buildModelIndex(spec: NormaliseSpec): Map<string, string | 'ambiguous'>;
|
|
109
|
+
declare function detectMatchesInComponent(spec: NormaliseSpec, modelIndex: Map<string, string | 'ambiguous'>, componentName: string, componentBody: Record<string, unknown>): CrudMatchData[];
|
|
110
|
+
export declare const CRUD_INTO_CURVED_RULE: NormaliseRule;
|
|
111
|
+
export declare const __internal: {
|
|
112
|
+
parseCrudOpName: typeof parseCrudOpName;
|
|
113
|
+
buildModelIndex: typeof buildModelIndex;
|
|
114
|
+
detectMatchesInComponent: typeof detectMatchesInComponent;
|
|
115
|
+
};
|
|
116
|
+
export {};
|
|
117
|
+
//# sourceMappingURL=crud-into-curved.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crud-into-curved.d.ts","sourceRoot":"","sources":["../../../src/normalise/rules/crud-into-curved.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EG;AAEH,OAAO,KAAK,EACV,aAAa,EAGb,aAAa,EACd,MAAM,uBAAuB,CAAC;AAE/B,UAAU,aAAa;IACrB,4CAA4C;IAC5C,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mEAAmE;IACnE,mBAAmB,EAAE,MAAM,CAAC;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;CACvD;AAYD;;;;;;;;GAQG;AACH,iBAAS,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAoBnH;AAED;;;;;GAKG;AACH,iBAAS,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAAC,CAgB/E;AAED,iBAAS,wBAAwB,CAC/B,IAAI,EAAE,aAAa,EACnB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAAC,EAC7C,aAAa,EAAE,MAAM,EACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACrC,aAAa,EAAE,CAuCjB;AAmFD,eAAO,MAAM,qBAAqB,EAAE,aAmCnC,CAAC;AAEF,eAAO,MAAM,UAAU;;;;CAItB,CAAC"}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalise rule: `crud-into-curved`.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3 of `2026-05-13-NORMALISE-PHASE.md` §5 Rule 2. Lifts Service
|
|
5
|
+
* operations that match the canonical CRUD shape onto their target
|
|
6
|
+
* entity's Controller `cured:` block. This eliminates redundant service
|
|
7
|
+
* shells for plain CRUD wrappers — SpecVerse generates
|
|
8
|
+
* `cured.retrieve/create/update/delete` from a controller's cured
|
|
9
|
+
* declaration, so an explicit `Service.getClub` that just does
|
|
10
|
+
* "look up by id" is duplicating convention.
|
|
11
|
+
*
|
|
12
|
+
* Schema note: CURVED operations live on Controllers, NOT on Models.
|
|
13
|
+
* If the target entity has no Controller declared, the rule synthesises
|
|
14
|
+
* a `<Entity>Controller` (with `model: <Entity>`) in the same component
|
|
15
|
+
* as the Model. The cured op lands there.
|
|
16
|
+
*
|
|
17
|
+
* Before (post-Rule-1 / cluster-module-functions):
|
|
18
|
+
*
|
|
19
|
+
* components:
|
|
20
|
+
* App:
|
|
21
|
+
* models:
|
|
22
|
+
* Club:
|
|
23
|
+
* attributes: [...]
|
|
24
|
+
* services:
|
|
25
|
+
* ClubsService:
|
|
26
|
+
* operations:
|
|
27
|
+
* getClub:
|
|
28
|
+
* steps: [Look up Club by id, Return Club or null]
|
|
29
|
+
* requires: [id is provided]
|
|
30
|
+
* getClubPositions: # NOT lifted (filtered query)
|
|
31
|
+
* steps: [...]
|
|
32
|
+
*
|
|
33
|
+
* After:
|
|
34
|
+
*
|
|
35
|
+
* components:
|
|
36
|
+
* App:
|
|
37
|
+
* models:
|
|
38
|
+
* Club:
|
|
39
|
+
* attributes: [...]
|
|
40
|
+
* controllers:
|
|
41
|
+
* ClubController: # synthesised by this rule
|
|
42
|
+
* model: Club
|
|
43
|
+
* cured:
|
|
44
|
+
* retrieve:
|
|
45
|
+
* steps: [Look up Club by id, Return Club or null]
|
|
46
|
+
* requires: [id is provided]
|
|
47
|
+
* services:
|
|
48
|
+
* ClubsService:
|
|
49
|
+
* operations:
|
|
50
|
+
* getClubPositions: { ... } # remains
|
|
51
|
+
*
|
|
52
|
+
* If the service becomes empty after lifting, it is dropped entirely.
|
|
53
|
+
*
|
|
54
|
+
* Conservative matching:
|
|
55
|
+
* - Op name must be EXACTLY `<verb><EntityName>` — no trailing suffix
|
|
56
|
+
* (`getRound` ✓, `getRoundsForUser` ✗, `getRoundStatus` ✗)
|
|
57
|
+
* - Target Entity name must resolve UNIQUELY to ONE model across all
|
|
58
|
+
* components (the cured block goes to whichever component the model
|
|
59
|
+
* lives in — cross-component lifts are fine since the cured: lives
|
|
60
|
+
* ON the model, not at the service site)
|
|
61
|
+
* - Skip when the entity name is declared in multiple components (rare,
|
|
62
|
+
* but ambiguous — caller can rename the duplicate)
|
|
63
|
+
* - Target model's `cured.<curvedOp>` must NOT already be declared
|
|
64
|
+
* (don't overwrite spec-author intent)
|
|
65
|
+
* - Verb patterns recognised:
|
|
66
|
+
* get / retrieve / find → retrieve
|
|
67
|
+
* create / add → create (Phase 3: only `create` to stay safe)
|
|
68
|
+
* update → update
|
|
69
|
+
* delete / remove → delete (Phase 3: only `delete` to stay safe)
|
|
70
|
+
*
|
|
71
|
+
* NOT in Phase 3 scope:
|
|
72
|
+
* - retrieve_many (list / getAll / search — naming varies too widely)
|
|
73
|
+
* - Evolve transitions (would need lifecycle state-machine signal)
|
|
74
|
+
* - Cross-component lifts (target model in a different component)
|
|
75
|
+
* - Multi-arg create variants (createClubWithPositions)
|
|
76
|
+
*/
|
|
77
|
+
/** Verb → CURVED op mapping. Order matters for prefix matching (longer first). */
|
|
78
|
+
const VERB_MAP = [
|
|
79
|
+
{ verb: 'retrieve', curved: 'retrieve' },
|
|
80
|
+
{ verb: 'create', curved: 'create' },
|
|
81
|
+
{ verb: 'update', curved: 'update' },
|
|
82
|
+
{ verb: 'delete', curved: 'delete' },
|
|
83
|
+
{ verb: 'find', curved: 'retrieve' },
|
|
84
|
+
{ verb: 'get', curved: 'retrieve' },
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Parse an operation name into (verb, entityName) if it matches the
|
|
88
|
+
* exact `<verb><PascalCaseEntity>` shape. Returns null otherwise.
|
|
89
|
+
*
|
|
90
|
+
* The entity portion must be PascalCase, contain no further uppercase
|
|
91
|
+
* boundaries after the leading letter (i.e. single PascalCase word),
|
|
92
|
+
* AND must end at the end of the string (no trailing qualifier like
|
|
93
|
+
* `getRoundStatus` or `getClubsForUser`).
|
|
94
|
+
*/
|
|
95
|
+
function parseCrudOpName(opName) {
|
|
96
|
+
for (const { verb, curved } of VERB_MAP) {
|
|
97
|
+
if (!opName.startsWith(verb))
|
|
98
|
+
continue;
|
|
99
|
+
const rest = opName.slice(verb.length);
|
|
100
|
+
if (rest.length === 0)
|
|
101
|
+
continue;
|
|
102
|
+
// First character must be uppercase (start of entity name).
|
|
103
|
+
if (!(rest[0] >= 'A' && rest[0] <= 'Z'))
|
|
104
|
+
continue;
|
|
105
|
+
// Rest must be a SINGLE PascalCase word — no further capital letters
|
|
106
|
+
// after the first character. This rules out compound names like
|
|
107
|
+
// `getClubPositions` (Positions is a 2nd PascalCase token →
|
|
108
|
+
// domain-filtered query, NOT plain retrieve).
|
|
109
|
+
let hasSecondCapital = false;
|
|
110
|
+
for (let i = 1; i < rest.length; i++) {
|
|
111
|
+
const c = rest[i];
|
|
112
|
+
if (c >= 'A' && c <= 'Z') {
|
|
113
|
+
hasSecondCapital = true;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (hasSecondCapital)
|
|
118
|
+
continue;
|
|
119
|
+
return { verb, curved, entity: rest };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Build a model-name → component-name index across all components. Used
|
|
125
|
+
* by the cross-component target lookup. When an entity name is declared
|
|
126
|
+
* in multiple components, it's recorded as ambiguous (the rule then
|
|
127
|
+
* skips matches against that name).
|
|
128
|
+
*/
|
|
129
|
+
function buildModelIndex(spec) {
|
|
130
|
+
const out = new Map();
|
|
131
|
+
const components = spec.components;
|
|
132
|
+
if (!components)
|
|
133
|
+
return out;
|
|
134
|
+
for (const [componentName, componentBody] of Object.entries(components)) {
|
|
135
|
+
const models = componentBody?.models;
|
|
136
|
+
if (!models)
|
|
137
|
+
continue;
|
|
138
|
+
for (const modelName of Object.keys(models)) {
|
|
139
|
+
const existing = out.get(modelName);
|
|
140
|
+
if (existing === undefined)
|
|
141
|
+
out.set(modelName, componentName);
|
|
142
|
+
else if (existing !== componentName)
|
|
143
|
+
out.set(modelName, 'ambiguous');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function detectMatchesInComponent(spec, modelIndex, componentName, componentBody) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const services = componentBody.services;
|
|
151
|
+
if (!services)
|
|
152
|
+
return out;
|
|
153
|
+
for (const [serviceName, serviceBody] of Object.entries(services)) {
|
|
154
|
+
if (!serviceBody || typeof serviceBody !== 'object')
|
|
155
|
+
continue;
|
|
156
|
+
const ops = serviceBody.operations;
|
|
157
|
+
if (!ops || typeof ops !== 'object')
|
|
158
|
+
continue;
|
|
159
|
+
for (const opName of Object.keys(ops)) {
|
|
160
|
+
const parsed = parseCrudOpName(opName);
|
|
161
|
+
if (!parsed)
|
|
162
|
+
continue;
|
|
163
|
+
const targetComponent = modelIndex.get(parsed.entity);
|
|
164
|
+
if (!targetComponent || targetComponent === 'ambiguous')
|
|
165
|
+
continue;
|
|
166
|
+
// Look up the target controller's cured block (if a controller
|
|
167
|
+
// already exists for this model). Skip if the op is already
|
|
168
|
+
// declared. Controllers may be missing — that's fine; the
|
|
169
|
+
// transform will synthesise one.
|
|
170
|
+
const components = spec.components;
|
|
171
|
+
const targetComponentBody = components[targetComponent];
|
|
172
|
+
const existingController = findControllerForModel(targetComponentBody, parsed.entity);
|
|
173
|
+
if (existingController) {
|
|
174
|
+
const existingCured = existingController.cured;
|
|
175
|
+
if (existingCured && parsed.curved in existingCured)
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
out.push({
|
|
179
|
+
sourceComponentName: componentName,
|
|
180
|
+
targetComponentName: targetComponent,
|
|
181
|
+
modelName: parsed.entity,
|
|
182
|
+
serviceName,
|
|
183
|
+
operationName: opName,
|
|
184
|
+
curvedOp: parsed.curved,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Find the controller in `componentBody` whose `model:` field references
|
|
192
|
+
* the given entity name. Returns the controller body, or null if none.
|
|
193
|
+
* The controller's KEY in the controllers object is the controller name
|
|
194
|
+
* (e.g. ClubController); the `model:` field declares which model it
|
|
195
|
+
* operates on.
|
|
196
|
+
*/
|
|
197
|
+
function findControllerForModel(componentBody, modelName) {
|
|
198
|
+
const controllers = componentBody.controllers;
|
|
199
|
+
if (!controllers || typeof controllers !== 'object')
|
|
200
|
+
return null;
|
|
201
|
+
for (const ctrlBody of Object.values(controllers)) {
|
|
202
|
+
if (!ctrlBody || typeof ctrlBody !== 'object')
|
|
203
|
+
continue;
|
|
204
|
+
if (ctrlBody.model === modelName) {
|
|
205
|
+
return ctrlBody;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
function applyCrudMatch(spec, data) {
|
|
211
|
+
const components = spec.components;
|
|
212
|
+
const sourceComponent = components[data.sourceComponentName];
|
|
213
|
+
const targetComponent = components[data.targetComponentName];
|
|
214
|
+
const services = sourceComponent.services;
|
|
215
|
+
const serviceBody = services[data.serviceName];
|
|
216
|
+
const ops = serviceBody.operations;
|
|
217
|
+
const opBody = ops[data.operationName];
|
|
218
|
+
// Locate the target controller (synthesise one if absent).
|
|
219
|
+
let targetController = findControllerForModel(targetComponent, data.modelName);
|
|
220
|
+
let targetControllerName = null;
|
|
221
|
+
if (!targetController) {
|
|
222
|
+
targetControllerName = `${data.modelName}Controller`;
|
|
223
|
+
targetController = { model: data.modelName };
|
|
224
|
+
// Insert into the component's controllers map. Create the map if
|
|
225
|
+
// it doesn't exist (preserving the standard position in the
|
|
226
|
+
// component — models then controllers then services).
|
|
227
|
+
const existingControllers = targetComponent.controllers ?? {};
|
|
228
|
+
existingControllers[targetControllerName] = targetController;
|
|
229
|
+
targetComponent.controllers = existingControllers;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// Find the key of the existing controller (we have its body, need its name for description).
|
|
233
|
+
const allControllers = targetComponent.controllers;
|
|
234
|
+
for (const [k, v] of Object.entries(allControllers)) {
|
|
235
|
+
if (v === targetController) {
|
|
236
|
+
targetControllerName = k;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Insert under controller.cured.<curvedOp>. Preserve standard order.
|
|
242
|
+
const existingCured = targetController.cured ?? {};
|
|
243
|
+
const newCured = {};
|
|
244
|
+
const STANDARD_ORDER = ['create', 'update', 'retrieve', 'retrieve_many', 'validate', 'evolve', 'delete'];
|
|
245
|
+
const allKeys = new Set([...Object.keys(existingCured), data.curvedOp]);
|
|
246
|
+
for (const k of STANDARD_ORDER) {
|
|
247
|
+
if (allKeys.has(k)) {
|
|
248
|
+
newCured[k] = k === data.curvedOp ? opBody : existingCured[k];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const [k, v] of Object.entries(existingCured)) {
|
|
252
|
+
if (!STANDARD_ORDER.includes(k))
|
|
253
|
+
newCured[k] = v;
|
|
254
|
+
}
|
|
255
|
+
targetController.cured = newCured;
|
|
256
|
+
// Remove operation from service.
|
|
257
|
+
delete ops[data.operationName];
|
|
258
|
+
// If the service has no remaining operations, drop the service. If
|
|
259
|
+
// doing so leaves an empty services map, drop that too.
|
|
260
|
+
if (Object.keys(ops).length === 0) {
|
|
261
|
+
delete services[data.serviceName];
|
|
262
|
+
if (Object.keys(services).length === 0) {
|
|
263
|
+
delete sourceComponent.services;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return spec;
|
|
267
|
+
}
|
|
268
|
+
export const CRUD_INTO_CURVED_RULE = {
|
|
269
|
+
id: 'crud-into-curved',
|
|
270
|
+
summary: 'Lift Service operations matching <verb><Entity> exactly (where Entity is a model in the same component) to the model\'s cured: block. Drop service if empty.',
|
|
271
|
+
ordering: 10,
|
|
272
|
+
enabledByDefault: true,
|
|
273
|
+
appliesWhen: (ctx) => ctx.source !== 'hand-authored',
|
|
274
|
+
detect: (spec) => {
|
|
275
|
+
const matches = [];
|
|
276
|
+
const components = spec.components;
|
|
277
|
+
if (!components || typeof components !== 'object')
|
|
278
|
+
return matches;
|
|
279
|
+
const modelIndex = buildModelIndex(spec);
|
|
280
|
+
for (const [componentName, componentBody] of Object.entries(components)) {
|
|
281
|
+
if (!componentBody || typeof componentBody !== 'object')
|
|
282
|
+
continue;
|
|
283
|
+
const found = detectMatchesInComponent(spec, modelIndex, componentName, componentBody);
|
|
284
|
+
for (const data of found) {
|
|
285
|
+
const crossComp = data.sourceComponentName !== data.targetComponentName;
|
|
286
|
+
matches.push({
|
|
287
|
+
description: `${data.sourceComponentName}.${data.serviceName}.${data.operationName} → ${data.targetComponentName}.${data.modelName}.cured.${data.curvedOp}${crossComp ? ' (cross-component)' : ''}`,
|
|
288
|
+
data,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return matches;
|
|
293
|
+
},
|
|
294
|
+
transform: (spec, match) => {
|
|
295
|
+
return applyCrudMatch(spec, match.data);
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
export const __internal = {
|
|
299
|
+
parseCrudOpName,
|
|
300
|
+
buildModelIndex,
|
|
301
|
+
detectMatchesInComponent,
|
|
302
|
+
};
|
|
303
|
+
//# sourceMappingURL=crud-into-curved.js.map
|