@hyphaene/hexa-ts-kit 1.2.4 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-FV47ZLIM.js +2922 -0
- package/dist/cli.js +586 -3
- package/dist/mcp-server.js +5 -4
- package/package.json +2 -1
- package/dist/chunk-56VIQG3N.js +0 -1434
|
@@ -0,0 +1,2922 @@
|
|
|
1
|
+
// src/commands/lint.ts
|
|
2
|
+
import { resolve as resolve2 } from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
|
|
5
|
+
// src/lint/checkers/structure/colocation.ts
|
|
6
|
+
import fg from "fast-glob";
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { basename, dirname, join } from "path";
|
|
9
|
+
|
|
10
|
+
// src/lint/checkers/structure/naming.ts
|
|
11
|
+
import fg2 from "fast-glob";
|
|
12
|
+
import { readFile } from "fs/promises";
|
|
13
|
+
import { basename as basename2 } from "path";
|
|
14
|
+
|
|
15
|
+
// src/lint/checkers/structure/domains.ts
|
|
16
|
+
import fg3 from "fast-glob";
|
|
17
|
+
import { basename as basename3 } from "path";
|
|
18
|
+
|
|
19
|
+
// src/lint/checkers/ast/vue-component.ts
|
|
20
|
+
import fg4 from "fast-glob";
|
|
21
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
22
|
+
|
|
23
|
+
// src/lint/checkers/ast/rules-file.ts
|
|
24
|
+
import fg5 from "fast-glob";
|
|
25
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
26
|
+
|
|
27
|
+
// src/lint/checkers/ast/typescript.ts
|
|
28
|
+
import fg6 from "fast-glob";
|
|
29
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
30
|
+
|
|
31
|
+
// src/lint/rules.registry.ts
|
|
32
|
+
var rulesRegistry = {
|
|
33
|
+
// ==========================================================================
|
|
34
|
+
// Colocation Rules (COL-*)
|
|
35
|
+
// ==========================================================================
|
|
36
|
+
"COL-001": {
|
|
37
|
+
title: "Vue component outside domains",
|
|
38
|
+
severity: "error",
|
|
39
|
+
category: "colocation",
|
|
40
|
+
why: "All Vue components must live in src/domains/{domain}/{feature}/ for colocation.",
|
|
41
|
+
autoFixable: false
|
|
42
|
+
},
|
|
43
|
+
"COL-002": {
|
|
44
|
+
title: "Missing colocated composable",
|
|
45
|
+
severity: "error",
|
|
46
|
+
category: "colocation",
|
|
47
|
+
why: "Each Vue component should have a colocated composable for logic separation.",
|
|
48
|
+
autoFixable: false
|
|
49
|
+
},
|
|
50
|
+
"COL-003": {
|
|
51
|
+
title: "Missing colocated types",
|
|
52
|
+
severity: "warning",
|
|
53
|
+
category: "colocation",
|
|
54
|
+
why: "Types should be colocated with the component that uses them.",
|
|
55
|
+
autoFixable: false
|
|
56
|
+
},
|
|
57
|
+
"COL-004": {
|
|
58
|
+
title: "Missing colocated tests",
|
|
59
|
+
severity: "warning",
|
|
60
|
+
category: "colocation",
|
|
61
|
+
why: "Tests should be colocated with the code they test.",
|
|
62
|
+
autoFixable: false
|
|
63
|
+
},
|
|
64
|
+
"COL-005": {
|
|
65
|
+
title: "Forbidden src/components directory",
|
|
66
|
+
severity: "error",
|
|
67
|
+
category: "colocation",
|
|
68
|
+
why: "Root-level components/ directory violates colocation. Move to domains/.",
|
|
69
|
+
autoFixable: false
|
|
70
|
+
},
|
|
71
|
+
"COL-006": {
|
|
72
|
+
title: "Forbidden src/hooks or src/composables directory",
|
|
73
|
+
severity: "error",
|
|
74
|
+
category: "colocation",
|
|
75
|
+
why: "Root-level hooks/composables directories violate colocation. Move to domains/.",
|
|
76
|
+
autoFixable: false
|
|
77
|
+
},
|
|
78
|
+
"COL-007": {
|
|
79
|
+
title: "Forbidden src/services directory",
|
|
80
|
+
severity: "error",
|
|
81
|
+
category: "colocation",
|
|
82
|
+
why: "Root-level services/ directory violates colocation. Move to domains/.",
|
|
83
|
+
autoFixable: false
|
|
84
|
+
},
|
|
85
|
+
"COL-008": {
|
|
86
|
+
title: "Forbidden src/types directory",
|
|
87
|
+
severity: "error",
|
|
88
|
+
category: "colocation",
|
|
89
|
+
why: "Root-level types/ directory violates colocation. Move to domains/ or shared/.",
|
|
90
|
+
autoFixable: false
|
|
91
|
+
},
|
|
92
|
+
"COL-009": {
|
|
93
|
+
title: "Unused colocated file",
|
|
94
|
+
severity: "warning",
|
|
95
|
+
category: "colocation",
|
|
96
|
+
why: "Colocated files should be used by their parent component.",
|
|
97
|
+
autoFixable: false
|
|
98
|
+
},
|
|
99
|
+
"COL-010": {
|
|
100
|
+
title: "Cross-domain import",
|
|
101
|
+
severity: "error",
|
|
102
|
+
category: "colocation",
|
|
103
|
+
why: "Direct imports between domains create coupling. Use shared/ for common code.",
|
|
104
|
+
autoFixable: false
|
|
105
|
+
},
|
|
106
|
+
"COL-011": {
|
|
107
|
+
title: "Missing index barrel export",
|
|
108
|
+
severity: "warning",
|
|
109
|
+
category: "colocation",
|
|
110
|
+
why: "Features should expose a clean public API via index.ts.",
|
|
111
|
+
autoFixable: true
|
|
112
|
+
},
|
|
113
|
+
"COL-012": {
|
|
114
|
+
title: "Deep import into feature",
|
|
115
|
+
severity: "warning",
|
|
116
|
+
category: "colocation",
|
|
117
|
+
why: "Import from feature index, not internal files.",
|
|
118
|
+
autoFixable: false
|
|
119
|
+
},
|
|
120
|
+
// ==========================================================================
|
|
121
|
+
// Naming Rules (NAM-*)
|
|
122
|
+
// ==========================================================================
|
|
123
|
+
"NAM-001": {
|
|
124
|
+
title: "Vue file not PascalCase",
|
|
125
|
+
severity: "error",
|
|
126
|
+
category: "naming",
|
|
127
|
+
why: "Vue components must use PascalCase naming (e.g., MyComponent.vue).",
|
|
128
|
+
autoFixable: false
|
|
129
|
+
},
|
|
130
|
+
"NAM-002": {
|
|
131
|
+
title: "Composable missing use prefix",
|
|
132
|
+
severity: "error",
|
|
133
|
+
category: "naming",
|
|
134
|
+
why: "Composables must start with 'use' (e.g., useMyFeature.ts).",
|
|
135
|
+
autoFixable: false
|
|
136
|
+
},
|
|
137
|
+
"NAM-003": {
|
|
138
|
+
title: "Types file wrong suffix",
|
|
139
|
+
severity: "error",
|
|
140
|
+
category: "naming",
|
|
141
|
+
why: "Type files must use .types.ts suffix.",
|
|
142
|
+
autoFixable: false
|
|
143
|
+
},
|
|
144
|
+
"NAM-004": {
|
|
145
|
+
title: "Test file wrong suffix",
|
|
146
|
+
severity: "error",
|
|
147
|
+
category: "naming",
|
|
148
|
+
why: "Test files must use .test.ts or .spec.ts suffix.",
|
|
149
|
+
autoFixable: false
|
|
150
|
+
},
|
|
151
|
+
"NAM-005": {
|
|
152
|
+
title: "Feature folder not kebab-case",
|
|
153
|
+
severity: "error",
|
|
154
|
+
category: "naming",
|
|
155
|
+
why: "Feature folders must use kebab-case (e.g., my-feature/).",
|
|
156
|
+
autoFixable: false
|
|
157
|
+
},
|
|
158
|
+
"NAM-006": {
|
|
159
|
+
title: "Domain folder not kebab-case",
|
|
160
|
+
severity: "error",
|
|
161
|
+
category: "naming",
|
|
162
|
+
why: "Domain folders must use kebab-case.",
|
|
163
|
+
autoFixable: false
|
|
164
|
+
},
|
|
165
|
+
"NAM-007": {
|
|
166
|
+
title: "Constant not SCREAMING_SNAKE_CASE",
|
|
167
|
+
severity: "warning",
|
|
168
|
+
category: "naming",
|
|
169
|
+
why: "Constants should use SCREAMING_SNAKE_CASE.",
|
|
170
|
+
autoFixable: false
|
|
171
|
+
},
|
|
172
|
+
"NAM-008": {
|
|
173
|
+
title: "Interface missing I prefix",
|
|
174
|
+
severity: "info",
|
|
175
|
+
category: "naming",
|
|
176
|
+
why: "Consider using I prefix for interfaces (IMyInterface).",
|
|
177
|
+
autoFixable: false
|
|
178
|
+
},
|
|
179
|
+
"NAM-009": {
|
|
180
|
+
title: "Type alias wrong suffix",
|
|
181
|
+
severity: "info",
|
|
182
|
+
category: "naming",
|
|
183
|
+
why: "Consider using Type suffix for type aliases (MyType).",
|
|
184
|
+
autoFixable: false
|
|
185
|
+
},
|
|
186
|
+
"NAM-010": {
|
|
187
|
+
title: "Enum not PascalCase",
|
|
188
|
+
severity: "error",
|
|
189
|
+
category: "naming",
|
|
190
|
+
why: "Enums must use PascalCase naming.",
|
|
191
|
+
autoFixable: false
|
|
192
|
+
},
|
|
193
|
+
"NAM-011": {
|
|
194
|
+
title: "Query key not camelCase",
|
|
195
|
+
severity: "error",
|
|
196
|
+
category: "naming",
|
|
197
|
+
why: "Query keys must use camelCase.",
|
|
198
|
+
autoFixable: false
|
|
199
|
+
},
|
|
200
|
+
"NAM-012": {
|
|
201
|
+
title: "Event handler missing on prefix",
|
|
202
|
+
severity: "warning",
|
|
203
|
+
category: "naming",
|
|
204
|
+
why: "Event handlers should start with 'on' (e.g., onSubmit).",
|
|
205
|
+
autoFixable: false
|
|
206
|
+
},
|
|
207
|
+
// ==========================================================================
|
|
208
|
+
// Vue Component Rules (VUE-*)
|
|
209
|
+
// ==========================================================================
|
|
210
|
+
"VUE-001": {
|
|
211
|
+
title: "ref() in component",
|
|
212
|
+
severity: "error",
|
|
213
|
+
category: "vue",
|
|
214
|
+
why: "ref() should be in composable, not component. Components are for template binding.",
|
|
215
|
+
autoFixable: false
|
|
216
|
+
},
|
|
217
|
+
"VUE-002": {
|
|
218
|
+
title: "reactive() in component",
|
|
219
|
+
severity: "error",
|
|
220
|
+
category: "vue",
|
|
221
|
+
why: "reactive() should be in composable, not component.",
|
|
222
|
+
autoFixable: false
|
|
223
|
+
},
|
|
224
|
+
"VUE-003": {
|
|
225
|
+
title: "computed() in component",
|
|
226
|
+
severity: "error",
|
|
227
|
+
category: "vue",
|
|
228
|
+
why: "computed() should be in composable, not component.",
|
|
229
|
+
autoFixable: false
|
|
230
|
+
},
|
|
231
|
+
"VUE-004": {
|
|
232
|
+
title: "watch() in component",
|
|
233
|
+
severity: "error",
|
|
234
|
+
category: "vue",
|
|
235
|
+
why: "watch() should be in composable, not component.",
|
|
236
|
+
autoFixable: false
|
|
237
|
+
},
|
|
238
|
+
"VUE-005": {
|
|
239
|
+
title: "watchEffect() in component",
|
|
240
|
+
severity: "error",
|
|
241
|
+
category: "vue",
|
|
242
|
+
why: "watchEffect() should be in composable, not component.",
|
|
243
|
+
autoFixable: false
|
|
244
|
+
},
|
|
245
|
+
"VUE-006": {
|
|
246
|
+
title: "Complex logic in template",
|
|
247
|
+
severity: "warning",
|
|
248
|
+
category: "vue",
|
|
249
|
+
why: "Move complex expressions to computed properties in composable.",
|
|
250
|
+
autoFixable: false
|
|
251
|
+
},
|
|
252
|
+
"VUE-007": {
|
|
253
|
+
title: "Inline styles in template",
|
|
254
|
+
severity: "warning",
|
|
255
|
+
category: "vue",
|
|
256
|
+
why: "Use CSS classes instead of inline styles.",
|
|
257
|
+
autoFixable: false
|
|
258
|
+
},
|
|
259
|
+
"VUE-008": {
|
|
260
|
+
title: "Missing component name",
|
|
261
|
+
severity: "warning",
|
|
262
|
+
category: "vue",
|
|
263
|
+
why: "Components should have explicit names for debugging.",
|
|
264
|
+
autoFixable: true
|
|
265
|
+
},
|
|
266
|
+
"VUE-009": {
|
|
267
|
+
title: "v-if with v-for",
|
|
268
|
+
severity: "error",
|
|
269
|
+
category: "vue",
|
|
270
|
+
why: "v-if and v-for on same element is anti-pattern. Use computed or wrapper.",
|
|
271
|
+
autoFixable: false
|
|
272
|
+
},
|
|
273
|
+
"VUE-010": {
|
|
274
|
+
title: "Missing key in v-for",
|
|
275
|
+
severity: "error",
|
|
276
|
+
category: "vue",
|
|
277
|
+
why: "v-for requires :key for proper DOM diffing.",
|
|
278
|
+
autoFixable: false
|
|
279
|
+
},
|
|
280
|
+
"VUE-011": {
|
|
281
|
+
title: "Direct DOM manipulation",
|
|
282
|
+
severity: "error",
|
|
283
|
+
category: "vue",
|
|
284
|
+
why: "Avoid direct DOM manipulation. Use refs and Vue reactivity.",
|
|
285
|
+
autoFixable: false
|
|
286
|
+
},
|
|
287
|
+
"VUE-012": {
|
|
288
|
+
title: "Prop mutation",
|
|
289
|
+
severity: "error",
|
|
290
|
+
category: "vue",
|
|
291
|
+
why: "Never mutate props directly. Emit events instead.",
|
|
292
|
+
autoFixable: false
|
|
293
|
+
},
|
|
294
|
+
"VUE-013": {
|
|
295
|
+
title: "Missing prop validation",
|
|
296
|
+
severity: "warning",
|
|
297
|
+
category: "vue",
|
|
298
|
+
why: "Props should have type validation.",
|
|
299
|
+
autoFixable: false
|
|
300
|
+
},
|
|
301
|
+
// ==========================================================================
|
|
302
|
+
// Composable Rules (CMP-*)
|
|
303
|
+
// ==========================================================================
|
|
304
|
+
"CMP-001": {
|
|
305
|
+
title: "Missing return statement",
|
|
306
|
+
severity: "error",
|
|
307
|
+
category: "composable",
|
|
308
|
+
why: "Composables must return their reactive state and methods.",
|
|
309
|
+
autoFixable: false
|
|
310
|
+
},
|
|
311
|
+
"CMP-002": {
|
|
312
|
+
title: "Side effect outside onMounted",
|
|
313
|
+
severity: "warning",
|
|
314
|
+
category: "composable",
|
|
315
|
+
why: "Side effects should be in lifecycle hooks, not at module level.",
|
|
316
|
+
autoFixable: false
|
|
317
|
+
},
|
|
318
|
+
"CMP-003": {
|
|
319
|
+
title: "Missing cleanup in onUnmounted",
|
|
320
|
+
severity: "warning",
|
|
321
|
+
category: "composable",
|
|
322
|
+
why: "Subscriptions and listeners need cleanup to prevent memory leaks.",
|
|
323
|
+
autoFixable: false
|
|
324
|
+
},
|
|
325
|
+
"CMP-004": {
|
|
326
|
+
title: "Non-reactive return value",
|
|
327
|
+
severity: "warning",
|
|
328
|
+
category: "composable",
|
|
329
|
+
why: "Composables should return reactive values (ref, computed, reactive).",
|
|
330
|
+
autoFixable: false
|
|
331
|
+
},
|
|
332
|
+
"CMP-005": {
|
|
333
|
+
title: "Direct API call without error handling",
|
|
334
|
+
severity: "warning",
|
|
335
|
+
category: "composable",
|
|
336
|
+
why: "API calls should have try/catch or error handling.",
|
|
337
|
+
autoFixable: false
|
|
338
|
+
},
|
|
339
|
+
"CMP-006": {
|
|
340
|
+
title: "Hardcoded values",
|
|
341
|
+
severity: "info",
|
|
342
|
+
category: "composable",
|
|
343
|
+
why: "Consider extracting hardcoded values to constants or config.",
|
|
344
|
+
autoFixable: false
|
|
345
|
+
},
|
|
346
|
+
"CMP-007": {
|
|
347
|
+
title: "Missing TypeScript types",
|
|
348
|
+
severity: "warning",
|
|
349
|
+
category: "composable",
|
|
350
|
+
why: "Composable parameters and return type should be typed.",
|
|
351
|
+
autoFixable: false
|
|
352
|
+
},
|
|
353
|
+
"CMP-008": {
|
|
354
|
+
title: "Too many responsibilities",
|
|
355
|
+
severity: "warning",
|
|
356
|
+
category: "composable",
|
|
357
|
+
why: "Composable does too much. Consider splitting into smaller composables.",
|
|
358
|
+
autoFixable: false
|
|
359
|
+
},
|
|
360
|
+
"CMP-009": {
|
|
361
|
+
title: "Global state mutation",
|
|
362
|
+
severity: "error",
|
|
363
|
+
category: "composable",
|
|
364
|
+
why: "Avoid mutating global state directly. Use stores.",
|
|
365
|
+
autoFixable: false
|
|
366
|
+
},
|
|
367
|
+
"CMP-010": {
|
|
368
|
+
title: "Synchronous blocking operation",
|
|
369
|
+
severity: "warning",
|
|
370
|
+
category: "composable",
|
|
371
|
+
why: "Heavy operations should be async to avoid blocking UI.",
|
|
372
|
+
autoFixable: false
|
|
373
|
+
},
|
|
374
|
+
"CMP-011": {
|
|
375
|
+
title: "Missing loading state",
|
|
376
|
+
severity: "info",
|
|
377
|
+
category: "composable",
|
|
378
|
+
why: "Async operations should expose loading state.",
|
|
379
|
+
autoFixable: false
|
|
380
|
+
},
|
|
381
|
+
// ==========================================================================
|
|
382
|
+
// Rules File Rules (RUL-*)
|
|
383
|
+
// ==========================================================================
|
|
384
|
+
"RUL-001": {
|
|
385
|
+
title: "Missing rules file",
|
|
386
|
+
severity: "warning",
|
|
387
|
+
category: "rules",
|
|
388
|
+
why: "Features should have a .rules.ts file documenting business rules.",
|
|
389
|
+
autoFixable: false
|
|
390
|
+
},
|
|
391
|
+
"RUL-002": {
|
|
392
|
+
title: "Rules not exported",
|
|
393
|
+
severity: "error",
|
|
394
|
+
category: "rules",
|
|
395
|
+
why: "Rules must be exported for documentation generation.",
|
|
396
|
+
autoFixable: false
|
|
397
|
+
},
|
|
398
|
+
"RUL-003": {
|
|
399
|
+
title: "Rule missing description",
|
|
400
|
+
severity: "warning",
|
|
401
|
+
category: "rules",
|
|
402
|
+
why: "Each rule should have a human-readable description.",
|
|
403
|
+
autoFixable: false
|
|
404
|
+
},
|
|
405
|
+
"RUL-004": {
|
|
406
|
+
title: "Rule missing validation",
|
|
407
|
+
severity: "warning",
|
|
408
|
+
category: "rules",
|
|
409
|
+
why: "Rules should include validation logic.",
|
|
410
|
+
autoFixable: false
|
|
411
|
+
},
|
|
412
|
+
"RUL-005": {
|
|
413
|
+
title: "Orphan rule",
|
|
414
|
+
severity: "warning",
|
|
415
|
+
category: "rules",
|
|
416
|
+
why: "Rule is defined but not used in any composable.",
|
|
417
|
+
autoFixable: false
|
|
418
|
+
},
|
|
419
|
+
"RUL-006": {
|
|
420
|
+
title: "Inline business rule",
|
|
421
|
+
severity: "warning",
|
|
422
|
+
category: "rules",
|
|
423
|
+
why: "Business logic should be in .rules.ts, not inline in component.",
|
|
424
|
+
autoFixable: false
|
|
425
|
+
},
|
|
426
|
+
"RUL-007": {
|
|
427
|
+
title: "Missing rule test",
|
|
428
|
+
severity: "info",
|
|
429
|
+
category: "rules",
|
|
430
|
+
why: "Business rules should have unit tests.",
|
|
431
|
+
autoFixable: false
|
|
432
|
+
},
|
|
433
|
+
"RUL-008": {
|
|
434
|
+
title: "Rule complexity too high",
|
|
435
|
+
severity: "warning",
|
|
436
|
+
category: "rules",
|
|
437
|
+
why: "Complex rules should be broken down into smaller rules.",
|
|
438
|
+
autoFixable: false
|
|
439
|
+
},
|
|
440
|
+
"RUL-009": {
|
|
441
|
+
title: "Duplicate rule logic",
|
|
442
|
+
severity: "warning",
|
|
443
|
+
category: "rules",
|
|
444
|
+
why: "Same rule logic appears in multiple places. Centralize it.",
|
|
445
|
+
autoFixable: false
|
|
446
|
+
},
|
|
447
|
+
"RUL-010": {
|
|
448
|
+
title: "Rule without error message",
|
|
449
|
+
severity: "warning",
|
|
450
|
+
category: "rules",
|
|
451
|
+
why: "Rules should define user-facing error messages.",
|
|
452
|
+
autoFixable: false
|
|
453
|
+
},
|
|
454
|
+
"RUL-011": {
|
|
455
|
+
title: "Magic number in rule",
|
|
456
|
+
severity: "warning",
|
|
457
|
+
category: "rules",
|
|
458
|
+
why: "Extract magic numbers to named constants.",
|
|
459
|
+
autoFixable: false
|
|
460
|
+
},
|
|
461
|
+
"RUL-012": {
|
|
462
|
+
title: "Rule depends on external state",
|
|
463
|
+
severity: "warning",
|
|
464
|
+
category: "rules",
|
|
465
|
+
why: "Rules should be pure functions. Pass dependencies as parameters.",
|
|
466
|
+
autoFixable: false
|
|
467
|
+
},
|
|
468
|
+
// ==========================================================================
|
|
469
|
+
// Query Rules (QRY-*)
|
|
470
|
+
// ==========================================================================
|
|
471
|
+
"QRY-001": {
|
|
472
|
+
title: "Query key not array",
|
|
473
|
+
severity: "error",
|
|
474
|
+
category: "query",
|
|
475
|
+
why: "Query keys must be arrays for proper cache invalidation.",
|
|
476
|
+
autoFixable: true
|
|
477
|
+
},
|
|
478
|
+
"QRY-002": {
|
|
479
|
+
title: "Missing query key factory",
|
|
480
|
+
severity: "warning",
|
|
481
|
+
category: "query",
|
|
482
|
+
why: "Use query key factories for consistency.",
|
|
483
|
+
autoFixable: false
|
|
484
|
+
},
|
|
485
|
+
"QRY-003": {
|
|
486
|
+
title: "Hardcoded stale time",
|
|
487
|
+
severity: "info",
|
|
488
|
+
category: "query",
|
|
489
|
+
why: "Consider extracting stale time to config.",
|
|
490
|
+
autoFixable: false
|
|
491
|
+
},
|
|
492
|
+
"QRY-004": {
|
|
493
|
+
title: "Missing error handling",
|
|
494
|
+
severity: "warning",
|
|
495
|
+
category: "query",
|
|
496
|
+
why: "Queries should handle errors gracefully.",
|
|
497
|
+
autoFixable: false
|
|
498
|
+
},
|
|
499
|
+
"QRY-005": {
|
|
500
|
+
title: "Query in component",
|
|
501
|
+
severity: "error",
|
|
502
|
+
category: "query",
|
|
503
|
+
why: "useQuery should be in composable, not component.",
|
|
504
|
+
autoFixable: false
|
|
505
|
+
},
|
|
506
|
+
"QRY-006": {
|
|
507
|
+
title: "Missing enabled condition",
|
|
508
|
+
severity: "info",
|
|
509
|
+
category: "query",
|
|
510
|
+
why: "Consider using enabled option for conditional queries.",
|
|
511
|
+
autoFixable: false
|
|
512
|
+
},
|
|
513
|
+
"QRY-007": {
|
|
514
|
+
title: "Mutation without invalidation",
|
|
515
|
+
severity: "warning",
|
|
516
|
+
category: "query",
|
|
517
|
+
why: "Mutations should invalidate related queries.",
|
|
518
|
+
autoFixable: false
|
|
519
|
+
},
|
|
520
|
+
"QRY-008": {
|
|
521
|
+
title: "Direct fetch instead of query",
|
|
522
|
+
severity: "warning",
|
|
523
|
+
category: "query",
|
|
524
|
+
why: "Use useQuery/useMutation for API calls to get caching benefits.",
|
|
525
|
+
autoFixable: false
|
|
526
|
+
},
|
|
527
|
+
// ==========================================================================
|
|
528
|
+
// Translation Rules (TRN-*)
|
|
529
|
+
// ==========================================================================
|
|
530
|
+
"TRN-001": {
|
|
531
|
+
title: "Hardcoded string",
|
|
532
|
+
severity: "warning",
|
|
533
|
+
category: "translations",
|
|
534
|
+
why: "User-facing strings should use i18n.",
|
|
535
|
+
autoFixable: false
|
|
536
|
+
},
|
|
537
|
+
"TRN-002": {
|
|
538
|
+
title: "Missing translation key",
|
|
539
|
+
severity: "error",
|
|
540
|
+
category: "translations",
|
|
541
|
+
why: "Translation key not found in any locale file.",
|
|
542
|
+
autoFixable: false
|
|
543
|
+
},
|
|
544
|
+
"TRN-003": {
|
|
545
|
+
title: "Unused translation key",
|
|
546
|
+
severity: "warning",
|
|
547
|
+
category: "translations",
|
|
548
|
+
why: "Translation key is defined but never used.",
|
|
549
|
+
autoFixable: false
|
|
550
|
+
},
|
|
551
|
+
"TRN-004": {
|
|
552
|
+
title: "Inconsistent translation keys",
|
|
553
|
+
severity: "warning",
|
|
554
|
+
category: "translations",
|
|
555
|
+
why: "Key exists in some locales but not all.",
|
|
556
|
+
autoFixable: false
|
|
557
|
+
},
|
|
558
|
+
"TRN-005": {
|
|
559
|
+
title: "Non-namespaced translation key",
|
|
560
|
+
severity: "info",
|
|
561
|
+
category: "translations",
|
|
562
|
+
why: "Translation keys should be namespaced by feature.",
|
|
563
|
+
autoFixable: false
|
|
564
|
+
},
|
|
565
|
+
// ==========================================================================
|
|
566
|
+
// TypeScript Rules (TSP-*)
|
|
567
|
+
// ==========================================================================
|
|
568
|
+
"TSP-001": {
|
|
569
|
+
title: "any type usage",
|
|
570
|
+
severity: "error",
|
|
571
|
+
category: "typescript",
|
|
572
|
+
why: "Avoid 'any'. Use 'unknown' or proper types.",
|
|
573
|
+
autoFixable: false
|
|
574
|
+
},
|
|
575
|
+
"TSP-002": {
|
|
576
|
+
title: "Type assertion without check",
|
|
577
|
+
severity: "warning",
|
|
578
|
+
category: "typescript",
|
|
579
|
+
why: "Type assertions (as X) should be preceded by runtime checks.",
|
|
580
|
+
autoFixable: false
|
|
581
|
+
},
|
|
582
|
+
"TSP-003": {
|
|
583
|
+
title: "Non-null assertion",
|
|
584
|
+
severity: "warning",
|
|
585
|
+
category: "typescript",
|
|
586
|
+
why: "Avoid ! operator. Use proper null checks.",
|
|
587
|
+
autoFixable: false
|
|
588
|
+
},
|
|
589
|
+
"TSP-004": {
|
|
590
|
+
title: "Implicit any in function",
|
|
591
|
+
severity: "error",
|
|
592
|
+
category: "typescript",
|
|
593
|
+
why: "Function parameters must have explicit types.",
|
|
594
|
+
autoFixable: false
|
|
595
|
+
},
|
|
596
|
+
"TSP-005": {
|
|
597
|
+
title: "Missing return type",
|
|
598
|
+
severity: "warning",
|
|
599
|
+
category: "typescript",
|
|
600
|
+
why: "Functions should have explicit return types.",
|
|
601
|
+
autoFixable: false
|
|
602
|
+
},
|
|
603
|
+
"TSP-006": {
|
|
604
|
+
title: "Type import not type-only",
|
|
605
|
+
severity: "info",
|
|
606
|
+
category: "typescript",
|
|
607
|
+
why: "Use 'import type' for type-only imports.",
|
|
608
|
+
autoFixable: true
|
|
609
|
+
},
|
|
610
|
+
"TSP-007": {
|
|
611
|
+
title: "Enum instead of const object",
|
|
612
|
+
severity: "info",
|
|
613
|
+
category: "typescript",
|
|
614
|
+
why: "Consider using const objects instead of enums for better tree-shaking.",
|
|
615
|
+
autoFixable: false
|
|
616
|
+
},
|
|
617
|
+
"TSP-008": {
|
|
618
|
+
title: "Object type instead of Record",
|
|
619
|
+
severity: "info",
|
|
620
|
+
category: "typescript",
|
|
621
|
+
why: "Use Record<K, V> instead of { [key: K]: V }.",
|
|
622
|
+
autoFixable: true
|
|
623
|
+
},
|
|
624
|
+
"TSP-009": {
|
|
625
|
+
title: "Function type instead of arrow",
|
|
626
|
+
severity: "info",
|
|
627
|
+
category: "typescript",
|
|
628
|
+
why: "Use arrow function type: (x: T) => R instead of Function.",
|
|
629
|
+
autoFixable: false
|
|
630
|
+
},
|
|
631
|
+
"TSP-010": {
|
|
632
|
+
title: "Redundant type annotation",
|
|
633
|
+
severity: "info",
|
|
634
|
+
category: "typescript",
|
|
635
|
+
why: "Type can be inferred. Remove redundant annotation.",
|
|
636
|
+
autoFixable: true
|
|
637
|
+
},
|
|
638
|
+
"TSP-011": {
|
|
639
|
+
title: "Unsafe optional chaining",
|
|
640
|
+
severity: "warning",
|
|
641
|
+
category: "typescript",
|
|
642
|
+
why: "Optional chaining on non-nullable type suggests incorrect types.",
|
|
643
|
+
autoFixable: false
|
|
644
|
+
},
|
|
645
|
+
"TSP-012": {
|
|
646
|
+
title: "Missing generic constraint",
|
|
647
|
+
severity: "info",
|
|
648
|
+
category: "typescript",
|
|
649
|
+
why: "Generics should have constraints when possible.",
|
|
650
|
+
autoFixable: false
|
|
651
|
+
},
|
|
652
|
+
// ==========================================================================
|
|
653
|
+
// Domain Rules (DOM-*)
|
|
654
|
+
// ==========================================================================
|
|
655
|
+
"DOM-001": {
|
|
656
|
+
title: "Missing domain index",
|
|
657
|
+
severity: "warning",
|
|
658
|
+
category: "domain",
|
|
659
|
+
why: "Domains should have index.ts exposing public API.",
|
|
660
|
+
autoFixable: true
|
|
661
|
+
},
|
|
662
|
+
"DOM-002": {
|
|
663
|
+
title: "Circular domain dependency",
|
|
664
|
+
severity: "error",
|
|
665
|
+
category: "domain",
|
|
666
|
+
why: "Domains should not have circular dependencies.",
|
|
667
|
+
autoFixable: false
|
|
668
|
+
},
|
|
669
|
+
"DOM-003": {
|
|
670
|
+
title: "Shared code in domain",
|
|
671
|
+
severity: "warning",
|
|
672
|
+
category: "domain",
|
|
673
|
+
why: "Code used by multiple domains should be in shared/.",
|
|
674
|
+
autoFixable: false
|
|
675
|
+
},
|
|
676
|
+
"DOM-004": {
|
|
677
|
+
title: "Domain too large",
|
|
678
|
+
severity: "info",
|
|
679
|
+
category: "domain",
|
|
680
|
+
why: "Consider splitting large domains into sub-domains.",
|
|
681
|
+
autoFixable: false
|
|
682
|
+
},
|
|
683
|
+
// ==========================================================================
|
|
684
|
+
// Contracts Rules (CTR-*)
|
|
685
|
+
// ==========================================================================
|
|
686
|
+
"CTR-001": {
|
|
687
|
+
title: "Missing metadata",
|
|
688
|
+
severity: "error",
|
|
689
|
+
category: "contracts",
|
|
690
|
+
why: "Every contract endpoint must have metadata for documentation and tooling.",
|
|
691
|
+
autoFixable: false,
|
|
692
|
+
examples: {
|
|
693
|
+
invalid: "contracts/CTR-001-invalid.ts",
|
|
694
|
+
valid: "contracts/CTR-001-valid.ts"
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
"CTR-002": {
|
|
698
|
+
title: "Missing openApiTags",
|
|
699
|
+
severity: "error",
|
|
700
|
+
category: "contracts",
|
|
701
|
+
why: "Endpoints must have openApiTags for API documentation grouping.",
|
|
702
|
+
autoFixable: false,
|
|
703
|
+
examples: {
|
|
704
|
+
invalid: "contracts/CTR-002-invalid.ts",
|
|
705
|
+
valid: "contracts/CTR-002-valid.ts"
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
"CTR-003": {
|
|
709
|
+
title: "Legacy openapi format",
|
|
710
|
+
severity: "error",
|
|
711
|
+
category: "contracts",
|
|
712
|
+
why: "Use metadata.openApiTags instead of metadata.openapi.tags.",
|
|
713
|
+
autoFixable: true,
|
|
714
|
+
examples: {
|
|
715
|
+
invalid: "contracts/CTR-003-invalid.ts",
|
|
716
|
+
valid: "contracts/CTR-003-valid.ts"
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
"CTR-004": {
|
|
720
|
+
title: "Missing permission",
|
|
721
|
+
severity: "error",
|
|
722
|
+
category: "contracts",
|
|
723
|
+
why: "Every endpoint must declare its requiredPermission for access control.",
|
|
724
|
+
autoFixable: false,
|
|
725
|
+
examples: {
|
|
726
|
+
invalid: "contracts/CTR-004-invalid.ts",
|
|
727
|
+
valid: "contracts/CTR-004-valid.ts"
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
"CTR-005": {
|
|
731
|
+
title: "Invalid permission value",
|
|
732
|
+
severity: "error",
|
|
733
|
+
category: "contracts",
|
|
734
|
+
why: "Permission must be a valid value from the Permission enum.",
|
|
735
|
+
autoFixable: false
|
|
736
|
+
},
|
|
737
|
+
"CTR-006": {
|
|
738
|
+
title: "Invalid apiService value",
|
|
739
|
+
severity: "error",
|
|
740
|
+
category: "contracts",
|
|
741
|
+
why: "apiService must be a valid value from the ApiService enum.",
|
|
742
|
+
autoFixable: false
|
|
743
|
+
},
|
|
744
|
+
"CTR-007": {
|
|
745
|
+
title: "Missing strictStatusCodes",
|
|
746
|
+
severity: "warning",
|
|
747
|
+
category: "contracts",
|
|
748
|
+
why: "Contracts should use strictStatusCodes: true for type safety.",
|
|
749
|
+
autoFixable: true,
|
|
750
|
+
examples: {
|
|
751
|
+
invalid: "contracts/CTR-007-invalid.ts",
|
|
752
|
+
valid: "contracts/CTR-007-valid.ts"
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
var allRuleIds = Object.keys(rulesRegistry);
|
|
757
|
+
function getRulesByCategory(category) {
|
|
758
|
+
return allRuleIds.filter((id) => rulesRegistry[id].category === category);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/lib/contracts/config-loader.ts
|
|
762
|
+
import { readFileSync, existsSync as existsSync2 } from "fs";
|
|
763
|
+
import { resolve, join as join2 } from "path";
|
|
764
|
+
import { parse as parseYaml } from "yaml";
|
|
765
|
+
import { z } from "zod";
|
|
766
|
+
var SourceExtractConfigSchema = z.object({
|
|
767
|
+
source: z.string(),
|
|
768
|
+
export: z.string(),
|
|
769
|
+
extract: z.enum(["const-values", "zod-keys"]).default("const-values")
|
|
770
|
+
});
|
|
771
|
+
var ContractsLintConfigSchema = z.object({
|
|
772
|
+
metadata: z.object({
|
|
773
|
+
permissions: SourceExtractConfigSchema,
|
|
774
|
+
apiServices: SourceExtractConfigSchema
|
|
775
|
+
}),
|
|
776
|
+
disabledRules: z.array(z.string()).optional()
|
|
777
|
+
});
|
|
778
|
+
var ContractsScaffoldConfigSchema = z.object({
|
|
779
|
+
path: z.string(),
|
|
780
|
+
// Absolute path to contracts lib
|
|
781
|
+
importPath: z.string()
|
|
782
|
+
// Import path for generated files
|
|
783
|
+
});
|
|
784
|
+
var ProjectConfigSchema = z.object({
|
|
785
|
+
type: z.enum(["contracts-lib", "nestjs-bff", "vue-frontend"])
|
|
786
|
+
});
|
|
787
|
+
var HexaTsKitConfigSchema = z.object({
|
|
788
|
+
project: ProjectConfigSchema,
|
|
789
|
+
lint: z.object({
|
|
790
|
+
contracts: ContractsLintConfigSchema.optional()
|
|
791
|
+
}).optional(),
|
|
792
|
+
scaffold: z.object({
|
|
793
|
+
contracts: ContractsScaffoldConfigSchema.optional()
|
|
794
|
+
}).optional()
|
|
795
|
+
});
|
|
796
|
+
var CONFIG_FILE_NAME = ".hexa-ts-kit.yaml";
|
|
797
|
+
function loadConfig(repoPath) {
|
|
798
|
+
const absolutePath = resolve(repoPath);
|
|
799
|
+
const configPath = join2(absolutePath, CONFIG_FILE_NAME);
|
|
800
|
+
if (!existsSync2(configPath)) {
|
|
801
|
+
return {
|
|
802
|
+
success: false,
|
|
803
|
+
error: `Config file not found: ${configPath}`,
|
|
804
|
+
configPath
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
let content;
|
|
808
|
+
try {
|
|
809
|
+
content = readFileSync(configPath, "utf-8");
|
|
810
|
+
} catch (err) {
|
|
811
|
+
return {
|
|
812
|
+
success: false,
|
|
813
|
+
error: `Failed to read config file: ${err instanceof Error ? err.message : String(err)}`,
|
|
814
|
+
configPath
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
let rawConfig;
|
|
818
|
+
try {
|
|
819
|
+
rawConfig = parseYaml(content);
|
|
820
|
+
} catch (err) {
|
|
821
|
+
return {
|
|
822
|
+
success: false,
|
|
823
|
+
error: `Invalid YAML syntax: ${err instanceof Error ? err.message : String(err)}`,
|
|
824
|
+
configPath
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
const result = HexaTsKitConfigSchema.safeParse(rawConfig);
|
|
828
|
+
if (!result.success) {
|
|
829
|
+
const issues = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
830
|
+
return {
|
|
831
|
+
success: false,
|
|
832
|
+
error: `Invalid config:
|
|
833
|
+
${issues}`,
|
|
834
|
+
configPath
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
success: true,
|
|
839
|
+
config: result.data,
|
|
840
|
+
configPath
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function getPermissionsConfig(config) {
|
|
844
|
+
return config.lint?.contracts?.metadata?.permissions ?? null;
|
|
845
|
+
}
|
|
846
|
+
function getApiServicesConfig(config) {
|
|
847
|
+
return config.lint?.contracts?.metadata?.apiServices ?? null;
|
|
848
|
+
}
|
|
849
|
+
function resolveConfigPath(repoPath, relativePath) {
|
|
850
|
+
return resolve(repoPath, relativePath);
|
|
851
|
+
}
|
|
852
|
+
function getDisabledRules(config) {
|
|
853
|
+
return config.lint?.contracts?.disabledRules ?? [];
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/lib/contracts/permissions-extractor.ts
|
|
857
|
+
import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
|
|
858
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
859
|
+
function extractValues(sourcePath, config) {
|
|
860
|
+
if (!existsSync3(sourcePath)) {
|
|
861
|
+
return {
|
|
862
|
+
success: false,
|
|
863
|
+
error: `Source file not found: ${sourcePath}`,
|
|
864
|
+
sourcePath
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
let content;
|
|
868
|
+
try {
|
|
869
|
+
content = readFileSync2(sourcePath, "utf-8");
|
|
870
|
+
} catch (err) {
|
|
871
|
+
return {
|
|
872
|
+
success: false,
|
|
873
|
+
error: `Failed to read source file: ${err instanceof Error ? err.message : String(err)}`,
|
|
874
|
+
sourcePath
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
let ast;
|
|
878
|
+
try {
|
|
879
|
+
ast = parse(content, {
|
|
880
|
+
loc: true,
|
|
881
|
+
range: true
|
|
882
|
+
});
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return {
|
|
885
|
+
success: false,
|
|
886
|
+
error: `Failed to parse TypeScript: ${err instanceof Error ? err.message : String(err)}`,
|
|
887
|
+
sourcePath
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const exportName = config.export;
|
|
891
|
+
const extractMode = config.extract;
|
|
892
|
+
if (extractMode === "const-values") {
|
|
893
|
+
return extractConstValues(ast, exportName, sourcePath);
|
|
894
|
+
} else if (extractMode === "zod-keys") {
|
|
895
|
+
return extractZodKeys(ast, exportName, sourcePath);
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
success: false,
|
|
899
|
+
error: `Unknown extract mode: ${extractMode}`,
|
|
900
|
+
sourcePath
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function extractConstValues(ast, exportName, sourcePath) {
|
|
904
|
+
const values = [];
|
|
905
|
+
const keyToValue = {};
|
|
906
|
+
for (const node of ast.body) {
|
|
907
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") {
|
|
908
|
+
for (const declarator of node.declaration.declarations) {
|
|
909
|
+
if (declarator.id.type === "Identifier" && declarator.id.name === exportName && declarator.init) {
|
|
910
|
+
let objectExpr = null;
|
|
911
|
+
if (declarator.init.type === "TSAsExpression") {
|
|
912
|
+
const expr = declarator.init.expression;
|
|
913
|
+
if (expr.type === "ObjectExpression") {
|
|
914
|
+
objectExpr = expr;
|
|
915
|
+
}
|
|
916
|
+
} else if (declarator.init.type === "ObjectExpression") {
|
|
917
|
+
objectExpr = declarator.init;
|
|
918
|
+
}
|
|
919
|
+
if (objectExpr) {
|
|
920
|
+
for (const prop of objectExpr.properties) {
|
|
921
|
+
if (prop.type === "Property" && prop.value.type === "Literal" && typeof prop.value.value === "string") {
|
|
922
|
+
const value = prop.value.value;
|
|
923
|
+
values.push(value);
|
|
924
|
+
if (prop.key.type === "Identifier") {
|
|
925
|
+
keyToValue[prop.key.name] = value;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (values.length === 0) {
|
|
935
|
+
return {
|
|
936
|
+
success: false,
|
|
937
|
+
error: `Could not find const object '${exportName}' with string values in ${sourcePath}`,
|
|
938
|
+
sourcePath
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
return {
|
|
942
|
+
success: true,
|
|
943
|
+
values,
|
|
944
|
+
keyToValue,
|
|
945
|
+
sourcePath
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
function extractZodKeys(ast, exportName, sourcePath) {
|
|
949
|
+
const values = [];
|
|
950
|
+
for (const node of ast.body) {
|
|
951
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") {
|
|
952
|
+
for (const declarator of node.declaration.declarations) {
|
|
953
|
+
if (declarator.id.type === "Identifier" && declarator.id.name === exportName && declarator.init) {
|
|
954
|
+
const init = declarator.init;
|
|
955
|
+
if (init.type === "CallExpression" && init.callee.type === "MemberExpression" && init.callee.property.type === "Identifier" && init.callee.property.name === "object") {
|
|
956
|
+
const firstArg = init.arguments[0];
|
|
957
|
+
if (firstArg && firstArg.type === "ObjectExpression") {
|
|
958
|
+
for (const prop of firstArg.properties) {
|
|
959
|
+
if (prop.type === "Property" && prop.key.type === "Identifier") {
|
|
960
|
+
values.push(prop.key.name);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (values.length === 0) {
|
|
970
|
+
return {
|
|
971
|
+
success: false,
|
|
972
|
+
error: `Could not find zod schema '${exportName}' with z.object() in ${sourcePath}`,
|
|
973
|
+
sourcePath
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
success: true,
|
|
978
|
+
values,
|
|
979
|
+
sourcePath
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// src/lib/contracts/contract-parser.ts
|
|
984
|
+
import { readFileSync as readFileSync3, existsSync as existsSync4 } from "fs";
|
|
985
|
+
import fg7 from "fast-glob";
|
|
986
|
+
import { parse as parse2 } from "@typescript-eslint/typescript-estree";
|
|
987
|
+
async function findContractFiles(repoPath) {
|
|
988
|
+
const files = await fg7("**/*.contract.ts", {
|
|
989
|
+
cwd: repoPath,
|
|
990
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/worktrees/**"],
|
|
991
|
+
absolute: true
|
|
992
|
+
});
|
|
993
|
+
return files;
|
|
994
|
+
}
|
|
995
|
+
function parseContractFile(filePath) {
|
|
996
|
+
if (!existsSync4(filePath)) {
|
|
997
|
+
return {
|
|
998
|
+
success: false,
|
|
999
|
+
error: `Contract file not found: ${filePath}`,
|
|
1000
|
+
filePath
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
let content;
|
|
1004
|
+
try {
|
|
1005
|
+
content = readFileSync3(filePath, "utf-8");
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
return {
|
|
1008
|
+
success: false,
|
|
1009
|
+
error: `Failed to read contract file: ${err instanceof Error ? err.message : String(err)}`,
|
|
1010
|
+
filePath
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
let ast;
|
|
1014
|
+
try {
|
|
1015
|
+
ast = parse2(content, {
|
|
1016
|
+
loc: true,
|
|
1017
|
+
range: true
|
|
1018
|
+
});
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
return {
|
|
1021
|
+
success: false,
|
|
1022
|
+
error: `Failed to parse TypeScript: ${err instanceof Error ? err.message : String(err)}`,
|
|
1023
|
+
filePath
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const endpoints = [];
|
|
1027
|
+
let contractName = "UnknownContract";
|
|
1028
|
+
for (const node of ast.body) {
|
|
1029
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") {
|
|
1030
|
+
for (const declarator of node.declaration.declarations) {
|
|
1031
|
+
if (declarator.id.type === "Identifier") {
|
|
1032
|
+
contractName = declarator.id.name;
|
|
1033
|
+
const routerCall = findRouterCall(declarator.init);
|
|
1034
|
+
if (routerCall) {
|
|
1035
|
+
const parsedEndpoints = extractEndpointsFromRouter(
|
|
1036
|
+
routerCall,
|
|
1037
|
+
content
|
|
1038
|
+
);
|
|
1039
|
+
endpoints.push(...parsedEndpoints);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
filePath,
|
|
1047
|
+
contractName,
|
|
1048
|
+
endpoints
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
async function parseContracts(repoPath) {
|
|
1052
|
+
const files = await findContractFiles(repoPath);
|
|
1053
|
+
if (files.length === 0) {
|
|
1054
|
+
return {
|
|
1055
|
+
success: true,
|
|
1056
|
+
contracts: []
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
const contracts = [];
|
|
1060
|
+
const errors = [];
|
|
1061
|
+
for (const file of files) {
|
|
1062
|
+
const result = parseContractFile(file);
|
|
1063
|
+
if ("success" in result && !result.success) {
|
|
1064
|
+
errors.push(result.error);
|
|
1065
|
+
} else {
|
|
1066
|
+
contracts.push(result);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
if (errors.length > 0 && contracts.length === 0) {
|
|
1070
|
+
return {
|
|
1071
|
+
success: false,
|
|
1072
|
+
error: errors.join("\n")
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
return {
|
|
1076
|
+
success: true,
|
|
1077
|
+
contracts
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function findRouterCall(node) {
|
|
1081
|
+
if (!node) return null;
|
|
1082
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier" && node.callee.property.name === "router") {
|
|
1083
|
+
return node;
|
|
1084
|
+
}
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
function extractEndpointsFromRouter(routerCall, sourceContent) {
|
|
1088
|
+
const endpoints = [];
|
|
1089
|
+
const firstArg = routerCall.arguments[0];
|
|
1090
|
+
if (!firstArg || firstArg.type !== "ObjectExpression") {
|
|
1091
|
+
return endpoints;
|
|
1092
|
+
}
|
|
1093
|
+
const routerObject = firstArg;
|
|
1094
|
+
for (const prop of routerObject.properties) {
|
|
1095
|
+
if (prop.type !== "Property") continue;
|
|
1096
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1097
|
+
const endpointName = prop.key.name;
|
|
1098
|
+
const endpointLine = prop.loc?.start.line ?? 0;
|
|
1099
|
+
if (prop.value.type !== "ObjectExpression") continue;
|
|
1100
|
+
const endpoint = extractEndpointDetails(
|
|
1101
|
+
endpointName,
|
|
1102
|
+
prop.value,
|
|
1103
|
+
endpointLine,
|
|
1104
|
+
sourceContent
|
|
1105
|
+
);
|
|
1106
|
+
if (endpoint) {
|
|
1107
|
+
endpoints.push(endpoint);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return endpoints;
|
|
1111
|
+
}
|
|
1112
|
+
function extractEndpointDetails(name, objectExpr, line, sourceContent) {
|
|
1113
|
+
let method = "";
|
|
1114
|
+
let path = "";
|
|
1115
|
+
let metadata;
|
|
1116
|
+
let strictStatusCodes;
|
|
1117
|
+
for (const prop of objectExpr.properties) {
|
|
1118
|
+
if (prop.type !== "Property") continue;
|
|
1119
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1120
|
+
const propName = prop.key.name;
|
|
1121
|
+
if (propName === "method" && prop.value.type === "Literal") {
|
|
1122
|
+
method = String(prop.value.value);
|
|
1123
|
+
}
|
|
1124
|
+
if (propName === "path" && prop.value.type === "Literal") {
|
|
1125
|
+
path = String(prop.value.value);
|
|
1126
|
+
}
|
|
1127
|
+
if (propName === "metadata") {
|
|
1128
|
+
if (prop.value.type === "ObjectExpression" || prop.value.type === "CallExpression") {
|
|
1129
|
+
metadata = extractMetadata(prop.value, sourceContent);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (propName === "strictStatusCodes" && prop.value.type === "Literal") {
|
|
1133
|
+
strictStatusCodes = prop.value.value === true;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return {
|
|
1137
|
+
name,
|
|
1138
|
+
method,
|
|
1139
|
+
path,
|
|
1140
|
+
metadata,
|
|
1141
|
+
strictStatusCodes,
|
|
1142
|
+
line
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function extractMetadata(node, sourceContent) {
|
|
1146
|
+
if (node.type === "ObjectExpression") {
|
|
1147
|
+
return parseMetadataObject(node, sourceContent);
|
|
1148
|
+
}
|
|
1149
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === "contractMetadata") {
|
|
1150
|
+
const firstArg = node.arguments[0];
|
|
1151
|
+
if (firstArg && firstArg.type === "ObjectExpression") {
|
|
1152
|
+
return parseMetadataObject(firstArg, sourceContent);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return void 0;
|
|
1156
|
+
}
|
|
1157
|
+
function parseMetadataObject(objectExpr, sourceContent) {
|
|
1158
|
+
const metadata = {};
|
|
1159
|
+
for (const prop of objectExpr.properties) {
|
|
1160
|
+
if (prop.type !== "Property") continue;
|
|
1161
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1162
|
+
const propName = prop.key.name;
|
|
1163
|
+
if (propName === "openApiTags" && prop.value.type === "ArrayExpression") {
|
|
1164
|
+
metadata.openApiTags = extractStringArray(prop.value);
|
|
1165
|
+
}
|
|
1166
|
+
if (propName === "requiredPermission") {
|
|
1167
|
+
if (prop.value.type === "Literal" || prop.value.type === "MemberExpression") {
|
|
1168
|
+
const permValue = extractPermissionValue(prop.value, sourceContent);
|
|
1169
|
+
if (permValue) {
|
|
1170
|
+
metadata.requiredPermission = permValue;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (propName === "requiresAuth" && prop.value.type === "Literal") {
|
|
1175
|
+
metadata.requiresAuth = prop.value.value === true;
|
|
1176
|
+
}
|
|
1177
|
+
if (propName === "bff" && prop.value.type === "ObjectExpression") {
|
|
1178
|
+
metadata.bff = extractBffConfig(prop.value);
|
|
1179
|
+
}
|
|
1180
|
+
if (propName === "openapi" && prop.value.type === "ObjectExpression") {
|
|
1181
|
+
metadata.openapi = extractLegacyOpenApi(prop.value);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return metadata;
|
|
1185
|
+
}
|
|
1186
|
+
function extractStringArray(arrayExpr) {
|
|
1187
|
+
const values = [];
|
|
1188
|
+
for (const element of arrayExpr.elements) {
|
|
1189
|
+
if (element?.type === "Literal" && typeof element.value === "string") {
|
|
1190
|
+
values.push(element.value);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return values;
|
|
1194
|
+
}
|
|
1195
|
+
function extractPermissionValue(node, sourceContent) {
|
|
1196
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
1197
|
+
return node.value;
|
|
1198
|
+
}
|
|
1199
|
+
if (node.type === "MemberExpression") {
|
|
1200
|
+
if (node.range) {
|
|
1201
|
+
const [start, end] = node.range;
|
|
1202
|
+
return sourceContent.slice(start, end);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return void 0;
|
|
1206
|
+
}
|
|
1207
|
+
function extractBffConfig(objectExpr) {
|
|
1208
|
+
const bff = {};
|
|
1209
|
+
for (const prop of objectExpr.properties) {
|
|
1210
|
+
if (prop.type !== "Property") continue;
|
|
1211
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1212
|
+
if (prop.key.name === "apiServices" && prop.value.type === "ArrayExpression") {
|
|
1213
|
+
bff.apiServices = [];
|
|
1214
|
+
for (const element of prop.value.elements) {
|
|
1215
|
+
if (element?.type === "Literal" && typeof element.value === "string") {
|
|
1216
|
+
bff.apiServices.push(element.value);
|
|
1217
|
+
}
|
|
1218
|
+
if (element?.type === "MemberExpression") {
|
|
1219
|
+
if (element.property.type === "Identifier") {
|
|
1220
|
+
bff.apiServices.push(element.property.name);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return bff;
|
|
1227
|
+
}
|
|
1228
|
+
function extractLegacyOpenApi(objectExpr) {
|
|
1229
|
+
const openapi = {};
|
|
1230
|
+
for (const prop of objectExpr.properties) {
|
|
1231
|
+
if (prop.type !== "Property") continue;
|
|
1232
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1233
|
+
if (prop.key.name === "tags" && prop.value.type === "ArrayExpression") {
|
|
1234
|
+
openapi.tags = extractStringArray(prop.value);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return openapi;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// src/lib/contracts/metadata-validator.ts
|
|
1241
|
+
import { z as z2 } from "zod";
|
|
1242
|
+
function validateContracts(contracts, ctx) {
|
|
1243
|
+
const issues = [];
|
|
1244
|
+
for (const contract of contracts) {
|
|
1245
|
+
for (const endpoint of contract.endpoints) {
|
|
1246
|
+
const endpointIssues = validateEndpoint(endpoint, contract.filePath, ctx);
|
|
1247
|
+
issues.push(...endpointIssues);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return {
|
|
1251
|
+
valid: issues.filter((i) => i.severity === "error").length === 0,
|
|
1252
|
+
issues
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
function validateEndpoint(endpoint, filePath, ctx) {
|
|
1256
|
+
const issues = [];
|
|
1257
|
+
const { metadata, name, line } = endpoint;
|
|
1258
|
+
if (endpoint.strictStatusCodes !== true) {
|
|
1259
|
+
issues.push({
|
|
1260
|
+
rule: "CTR-007",
|
|
1261
|
+
severity: "error",
|
|
1262
|
+
message: `Endpoint '${name}' is missing strictStatusCodes: true`,
|
|
1263
|
+
endpointName: name,
|
|
1264
|
+
filePath,
|
|
1265
|
+
line,
|
|
1266
|
+
suggestion: "Add strictStatusCodes: true to the endpoint definition"
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
if (!metadata) {
|
|
1270
|
+
issues.push({
|
|
1271
|
+
rule: "CTR-001",
|
|
1272
|
+
severity: "error",
|
|
1273
|
+
message: `Endpoint '${name}' is missing metadata`,
|
|
1274
|
+
endpointName: name,
|
|
1275
|
+
filePath,
|
|
1276
|
+
line,
|
|
1277
|
+
suggestion: "Add metadata: contractMetadata({ openApiTags: [...], requiredPermission: Permission.XXX })"
|
|
1278
|
+
});
|
|
1279
|
+
return issues;
|
|
1280
|
+
}
|
|
1281
|
+
if (metadata.openapi?.tags) {
|
|
1282
|
+
issues.push({
|
|
1283
|
+
rule: "CTR-003",
|
|
1284
|
+
severity: "error",
|
|
1285
|
+
message: `Endpoint '${name}' uses legacy format metadata.openapi.tags`,
|
|
1286
|
+
endpointName: name,
|
|
1287
|
+
filePath,
|
|
1288
|
+
line,
|
|
1289
|
+
suggestion: "Migrate to openApiTags: [...] at the root of metadata"
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
if (!metadata.openApiTags || metadata.openApiTags.length === 0) {
|
|
1293
|
+
if (!metadata.openapi?.tags || metadata.openapi.tags.length === 0) {
|
|
1294
|
+
issues.push({
|
|
1295
|
+
rule: "CTR-002",
|
|
1296
|
+
severity: "error",
|
|
1297
|
+
message: `Endpoint '${name}' is missing openApiTags`,
|
|
1298
|
+
endpointName: name,
|
|
1299
|
+
filePath,
|
|
1300
|
+
line,
|
|
1301
|
+
suggestion: "Add openApiTags: ['TagName'] to metadata"
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (!metadata.requiredPermission) {
|
|
1306
|
+
if (metadata.requiresAuth === false) {
|
|
1307
|
+
issues.push({
|
|
1308
|
+
rule: "CTR-004",
|
|
1309
|
+
severity: "warning",
|
|
1310
|
+
message: `Endpoint '${name}' uses legacy requiresAuth: false instead of Permission.NO_PERMISSION`,
|
|
1311
|
+
endpointName: name,
|
|
1312
|
+
filePath,
|
|
1313
|
+
line,
|
|
1314
|
+
suggestion: "Replace with requiredPermission: Permission.NO_PERMISSION"
|
|
1315
|
+
});
|
|
1316
|
+
} else {
|
|
1317
|
+
issues.push({
|
|
1318
|
+
rule: "CTR-004",
|
|
1319
|
+
severity: "error",
|
|
1320
|
+
message: `Endpoint '${name}' is missing requiredPermission`,
|
|
1321
|
+
endpointName: name,
|
|
1322
|
+
filePath,
|
|
1323
|
+
line,
|
|
1324
|
+
suggestion: "Add requiredPermission: Permission.XXX (or Permission.NO_PERMISSION for public endpoints)"
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
if (metadata.requiredPermission && ctx.validPermissions.length > 0) {
|
|
1329
|
+
const permission = normalizePermissionValue(
|
|
1330
|
+
metadata.requiredPermission,
|
|
1331
|
+
ctx.permissionKeyToValue
|
|
1332
|
+
);
|
|
1333
|
+
if (!ctx.validPermissions.includes(permission)) {
|
|
1334
|
+
issues.push({
|
|
1335
|
+
rule: "CTR-005",
|
|
1336
|
+
severity: "error",
|
|
1337
|
+
message: `Endpoint '${name}' uses invalid permission '${metadata.requiredPermission}'`,
|
|
1338
|
+
endpointName: name,
|
|
1339
|
+
filePath,
|
|
1340
|
+
line,
|
|
1341
|
+
suggestion: `Valid permissions: ${ctx.validPermissions.slice(0, 5).join(", ")}...`
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (metadata.bff?.apiServices && ctx.validApiServices.length > 0) {
|
|
1346
|
+
for (const service of metadata.bff.apiServices) {
|
|
1347
|
+
const normalizedService = normalizeApiServiceValue(service);
|
|
1348
|
+
if (!ctx.validApiServices.includes(normalizedService)) {
|
|
1349
|
+
issues.push({
|
|
1350
|
+
rule: "CTR-006",
|
|
1351
|
+
severity: "error",
|
|
1352
|
+
message: `Endpoint '${name}' uses invalid apiService '${service}'`,
|
|
1353
|
+
endpointName: name,
|
|
1354
|
+
filePath,
|
|
1355
|
+
line,
|
|
1356
|
+
suggestion: `Valid services: ${ctx.validApiServices.join(", ")}`
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return issues;
|
|
1362
|
+
}
|
|
1363
|
+
function normalizePermissionValue(value, keyToValue) {
|
|
1364
|
+
if (value.includes(".")) {
|
|
1365
|
+
const parts = value.split(".");
|
|
1366
|
+
const enumKey = parts[parts.length - 1] ?? value;
|
|
1367
|
+
if (keyToValue && enumKey in keyToValue) {
|
|
1368
|
+
const resolved = keyToValue[enumKey];
|
|
1369
|
+
if (resolved) return resolved;
|
|
1370
|
+
}
|
|
1371
|
+
return enumKey;
|
|
1372
|
+
}
|
|
1373
|
+
return value;
|
|
1374
|
+
}
|
|
1375
|
+
function normalizeApiServiceValue(value) {
|
|
1376
|
+
const serviceMap = {
|
|
1377
|
+
SEE_CORE: "seeCore",
|
|
1378
|
+
LOCUS: "locus",
|
|
1379
|
+
SEE_BUDGET: "seeBudget",
|
|
1380
|
+
SMD: "smd",
|
|
1381
|
+
SEE_PROVIDER: "seeProvider"
|
|
1382
|
+
};
|
|
1383
|
+
if (serviceMap[value]) {
|
|
1384
|
+
return serviceMap[value];
|
|
1385
|
+
}
|
|
1386
|
+
return value;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/lib/contracts/generators/types.ts
|
|
1390
|
+
var API_SERVICE_CONFIG = {
|
|
1391
|
+
seeCore: {
|
|
1392
|
+
moduleName: "SeeCoreModule",
|
|
1393
|
+
moduleImport: "@/common/apis/seeCore/seeCore.module",
|
|
1394
|
+
serviceName: "SeeCoreService",
|
|
1395
|
+
serviceImport: "@/common/apis/seeCore/seeCore.service",
|
|
1396
|
+
configMethod: "getAPIConfig",
|
|
1397
|
+
configReturn: { baseUrl: "seeCoreBaseUrl", apiKey: "seeCoreApiKey" }
|
|
1398
|
+
},
|
|
1399
|
+
locus: {
|
|
1400
|
+
moduleName: "LocusModule",
|
|
1401
|
+
moduleImport: "@/common/apis/locus/locus.module",
|
|
1402
|
+
serviceName: "LocusService",
|
|
1403
|
+
serviceImport: "@/common/apis/locus/locus.service",
|
|
1404
|
+
configMethod: "getAPIConfig",
|
|
1405
|
+
configReturn: { baseUrl: "locusBaseUrl", apiKey: "locusApiKey" }
|
|
1406
|
+
},
|
|
1407
|
+
seeBudget: {
|
|
1408
|
+
moduleName: "SeeBudgetModule",
|
|
1409
|
+
moduleImport: "@/common/apis/seeBudget/seeBudget.module",
|
|
1410
|
+
serviceName: "SeeBudgetService",
|
|
1411
|
+
serviceImport: "@/common/apis/seeBudget/seeBudget.service",
|
|
1412
|
+
configMethod: "getAPIConfig",
|
|
1413
|
+
configReturn: { baseUrl: "seeBudgetBaseUrl", apiKey: "seeBudgetApiKey" }
|
|
1414
|
+
},
|
|
1415
|
+
smd: {
|
|
1416
|
+
moduleName: "SmdModule",
|
|
1417
|
+
moduleImport: "@/common/apis/smd/smd.module",
|
|
1418
|
+
serviceName: "SmdService",
|
|
1419
|
+
serviceImport: "@/common/apis/smd/smd.service",
|
|
1420
|
+
configMethod: "getAPIConfig",
|
|
1421
|
+
configReturn: { baseUrl: "smdBaseUrl", apiKey: "smdApiKey" }
|
|
1422
|
+
},
|
|
1423
|
+
seeProvider: {
|
|
1424
|
+
moduleName: "SeeProviderModule",
|
|
1425
|
+
moduleImport: "@/common/apis/seeProvider/seeProvider.module",
|
|
1426
|
+
serviceName: "SeeProviderService",
|
|
1427
|
+
serviceImport: "@/common/apis/seeProvider/seeProvider.service",
|
|
1428
|
+
configMethod: "getAPIConfig",
|
|
1429
|
+
configReturn: {
|
|
1430
|
+
baseUrl: "seeProviderBaseUrl",
|
|
1431
|
+
apiKey: "seeProviderApiKey"
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
function toCase(name, targetCase) {
|
|
1436
|
+
const words = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/_/g, " ").toLowerCase().split(" ").filter(Boolean);
|
|
1437
|
+
switch (targetCase) {
|
|
1438
|
+
case "pascal":
|
|
1439
|
+
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
1440
|
+
case "camel":
|
|
1441
|
+
return words.map((w, i) => i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
1442
|
+
case "snake":
|
|
1443
|
+
return words.join("_").toUpperCase();
|
|
1444
|
+
case "kebab":
|
|
1445
|
+
return words.join("-");
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
function toPermissionConstant(permissionValue) {
|
|
1449
|
+
if (permissionValue.includes(".")) {
|
|
1450
|
+
const parts = permissionValue.split(".");
|
|
1451
|
+
return parts[parts.length - 1] ?? permissionValue;
|
|
1452
|
+
}
|
|
1453
|
+
return permissionValue.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase();
|
|
1454
|
+
}
|
|
1455
|
+
function getSuccessStatus(responses) {
|
|
1456
|
+
if (!responses) return 200;
|
|
1457
|
+
const statusCodes = Object.keys(responses).map(Number).filter((code) => code >= 200 && code < 300).sort();
|
|
1458
|
+
return statusCodes[0] ?? 200;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// src/lib/contracts/generators/module.generator.ts
|
|
1462
|
+
function generateModule(ctx) {
|
|
1463
|
+
const { featureName, featureNameCamel, apiServices } = ctx;
|
|
1464
|
+
const apiModuleImports = apiServices.map((service) => {
|
|
1465
|
+
const config = API_SERVICE_CONFIG[service];
|
|
1466
|
+
if (!config) return null;
|
|
1467
|
+
return `import { ${config.moduleName} } from "${config.moduleImport}";`;
|
|
1468
|
+
}).filter(Boolean).join("\n");
|
|
1469
|
+
const apiModuleNames = apiServices.map((service) => API_SERVICE_CONFIG[service]?.moduleName).filter(Boolean);
|
|
1470
|
+
const content = `import { Module } from "@nestjs/common";
|
|
1471
|
+
${apiModuleImports}
|
|
1472
|
+
import { AuthorizationModule } from "@/common/security/authorization/module/authorization.module";
|
|
1473
|
+
import { TsRestClientModule } from "@/common/ts-rest/tsrestclient.module";
|
|
1474
|
+
|
|
1475
|
+
import { ${featureName}Config } from "./config/${featureNameCamel}.config";
|
|
1476
|
+
import { ${featureName}Controller } from "./controller/${featureNameCamel}.controller";
|
|
1477
|
+
import { ${featureName}Service } from "./service/${featureNameCamel}.service";
|
|
1478
|
+
import { ${featureNameCamel}QueryProvider } from "./service/${featureNameCamel}Query.factory";
|
|
1479
|
+
|
|
1480
|
+
@Module({
|
|
1481
|
+
controllers: [${featureName}Controller],
|
|
1482
|
+
providers: [${featureName}Service, ${featureName}Config, ${featureNameCamel}QueryProvider],
|
|
1483
|
+
imports: [AuthorizationModule, TsRestClientModule${apiModuleNames.length > 0 ? ", " + apiModuleNames.join(", ") : ""}],
|
|
1484
|
+
exports: [${featureName}Service],
|
|
1485
|
+
})
|
|
1486
|
+
export class ${featureName}Module {}
|
|
1487
|
+
`;
|
|
1488
|
+
return {
|
|
1489
|
+
path: `${featureNameCamel}.module.ts`,
|
|
1490
|
+
content,
|
|
1491
|
+
isComplete: true
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// src/lib/contracts/generators/controller.generator.ts
|
|
1496
|
+
function generateController(ctx) {
|
|
1497
|
+
const {
|
|
1498
|
+
featureName,
|
|
1499
|
+
featureNameCamel,
|
|
1500
|
+
contractName,
|
|
1501
|
+
contractsLib,
|
|
1502
|
+
endpoint,
|
|
1503
|
+
permissionConstant,
|
|
1504
|
+
successStatus
|
|
1505
|
+
} = ctx;
|
|
1506
|
+
const pathParams = extractPathParams(endpoint.path);
|
|
1507
|
+
const hasPathParams = pathParams.length > 0;
|
|
1508
|
+
const paramImports = hasPathParams ? ", Param" : "";
|
|
1509
|
+
const paramDecorators = pathParams.map((param) => `@Param("${param}") ${param}: string`).join(", ");
|
|
1510
|
+
const httpMethod = endpoint.method.charAt(0).toUpperCase() + endpoint.method.slice(1).toLowerCase();
|
|
1511
|
+
const needsBody = ["POST", "PUT", "PATCH"].includes(
|
|
1512
|
+
endpoint.method.toUpperCase()
|
|
1513
|
+
);
|
|
1514
|
+
const bodyImport = needsBody ? ", Body" : "";
|
|
1515
|
+
const serviceParams = pathParams.join(", ");
|
|
1516
|
+
const content = `import { ${contractName} } from "${contractsLib}";
|
|
1517
|
+
import { Controller, HttpCode, Inject${paramImports}${bodyImport}, ${httpMethod} } from "@nestjs/common";
|
|
1518
|
+
import { TsRestHandler, tsRestHandler } from "@ts-rest/nest";
|
|
1519
|
+
|
|
1520
|
+
import { Authorize } from "@/common/security/authorization/decorators/authorize.decorator";
|
|
1521
|
+
import { Permissions } from "@/common/security/session/constants/permissions.constants";
|
|
1522
|
+
|
|
1523
|
+
import { ${featureName}Service } from "../service/${featureNameCamel}.service";
|
|
1524
|
+
|
|
1525
|
+
@Controller()
|
|
1526
|
+
export class ${featureName}Controller {
|
|
1527
|
+
constructor(
|
|
1528
|
+
@Inject(${featureName}Service)
|
|
1529
|
+
private readonly ${featureNameCamel}Service: ${featureName}Service,
|
|
1530
|
+
) {}
|
|
1531
|
+
|
|
1532
|
+
@${httpMethod}("${endpoint.path}")
|
|
1533
|
+
@Authorize(Permissions.${permissionConstant})
|
|
1534
|
+
@TsRestHandler(${contractName}.${endpoint.name})
|
|
1535
|
+
@HttpCode(${successStatus})
|
|
1536
|
+
async ${endpoint.name}(${paramDecorators}) {
|
|
1537
|
+
return tsRestHandler(${contractName}.${endpoint.name}, async () => {
|
|
1538
|
+
await this.${featureNameCamel}Service.${endpoint.name}(${serviceParams});
|
|
1539
|
+
return { status: ${successStatus} as const, body: undefined };
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
`;
|
|
1544
|
+
return {
|
|
1545
|
+
path: `controller/${featureNameCamel}.controller.ts`,
|
|
1546
|
+
content,
|
|
1547
|
+
isComplete: true
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
function extractPathParams(path) {
|
|
1551
|
+
const matches = path.match(/:(\w+)/g);
|
|
1552
|
+
if (!matches) return [];
|
|
1553
|
+
return matches.map((m) => m.slice(1));
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/lib/contracts/generators/config.generator.ts
|
|
1557
|
+
function generateConfig(ctx) {
|
|
1558
|
+
const { featureName, featureNameCamel, apiServices } = ctx;
|
|
1559
|
+
const primaryService = apiServices[0];
|
|
1560
|
+
const config = primaryService ? API_SERVICE_CONFIG[primaryService] : null;
|
|
1561
|
+
if (!config) {
|
|
1562
|
+
const content2 = `import { Injectable } from "@nestjs/common";
|
|
1563
|
+
|
|
1564
|
+
@Injectable()
|
|
1565
|
+
export class ${featureName}Config {
|
|
1566
|
+
public get${featureName}Config() {
|
|
1567
|
+
return {};
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
`;
|
|
1571
|
+
return {
|
|
1572
|
+
path: `config/${featureNameCamel}.config.ts`,
|
|
1573
|
+
content: content2,
|
|
1574
|
+
isComplete: true
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
const serviceImports = apiServices.map((service) => {
|
|
1578
|
+
const svcConfig = API_SERVICE_CONFIG[service];
|
|
1579
|
+
if (!svcConfig) return null;
|
|
1580
|
+
return `import { ${svcConfig.serviceName} } from "${svcConfig.serviceImport}";`;
|
|
1581
|
+
}).filter(Boolean).join("\n");
|
|
1582
|
+
const constructorParams = apiServices.map((service) => {
|
|
1583
|
+
const svcConfig = API_SERVICE_CONFIG[service];
|
|
1584
|
+
if (!svcConfig) return null;
|
|
1585
|
+
const serviceCamel = svcConfig.serviceName.charAt(0).toLowerCase() + svcConfig.serviceName.slice(1);
|
|
1586
|
+
return ` @Inject(${svcConfig.serviceName}) private readonly ${serviceCamel}: ${svcConfig.serviceName},`;
|
|
1587
|
+
}).filter(Boolean).join("\n");
|
|
1588
|
+
const configReturns = apiServices.map((service) => {
|
|
1589
|
+
const svcConfig = API_SERVICE_CONFIG[service];
|
|
1590
|
+
if (!svcConfig) return null;
|
|
1591
|
+
const serviceCamel = svcConfig.serviceName.charAt(0).toLowerCase() + svcConfig.serviceName.slice(1);
|
|
1592
|
+
return ` const { ${svcConfig.configReturn.baseUrl}, ${svcConfig.configReturn.apiKey} } = this.${serviceCamel}.${svcConfig.configMethod}();`;
|
|
1593
|
+
}).filter(Boolean).join("\n");
|
|
1594
|
+
const returnProps = apiServices.map((service) => {
|
|
1595
|
+
const svcConfig = API_SERVICE_CONFIG[service];
|
|
1596
|
+
if (!svcConfig) return null;
|
|
1597
|
+
return `${svcConfig.configReturn.baseUrl}, ${svcConfig.configReturn.apiKey}`;
|
|
1598
|
+
}).filter(Boolean).join(", ");
|
|
1599
|
+
const content = `import { Inject, Injectable } from "@nestjs/common";
|
|
1600
|
+
${serviceImports}
|
|
1601
|
+
|
|
1602
|
+
@Injectable()
|
|
1603
|
+
export class ${featureName}Config {
|
|
1604
|
+
constructor(
|
|
1605
|
+
${constructorParams}
|
|
1606
|
+
) {}
|
|
1607
|
+
|
|
1608
|
+
public get${featureName}Config() {
|
|
1609
|
+
${configReturns}
|
|
1610
|
+
return { ${returnProps} };
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
`;
|
|
1614
|
+
return {
|
|
1615
|
+
path: `config/${featureNameCamel}.config.ts`,
|
|
1616
|
+
content,
|
|
1617
|
+
isComplete: true
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// src/lib/contracts/generators/service.generator.ts
|
|
1622
|
+
function generateService(ctx) {
|
|
1623
|
+
const { featureName, featureNameCamel, featureNameUpperSnake, endpoint } = ctx;
|
|
1624
|
+
const pathParams = extractPathParams2(endpoint.path);
|
|
1625
|
+
const methodParams = pathParams.map((p) => `${p}: string`).join(", ");
|
|
1626
|
+
const content = `import { Inject, Injectable } from "@nestjs/common";
|
|
1627
|
+
|
|
1628
|
+
import {
|
|
1629
|
+
${featureNameUpperSnake}_QUERY,
|
|
1630
|
+
${featureName}Query,
|
|
1631
|
+
} from "./${featureNameCamel}Query.factory";
|
|
1632
|
+
|
|
1633
|
+
@Injectable()
|
|
1634
|
+
export class ${featureName}Service {
|
|
1635
|
+
constructor(
|
|
1636
|
+
@Inject(${featureNameUpperSnake}_QUERY)
|
|
1637
|
+
private readonly ${featureNameCamel}Query: ${featureName}Query,
|
|
1638
|
+
) {}
|
|
1639
|
+
|
|
1640
|
+
async ${endpoint.name}(${methodParams}): Promise<void> {
|
|
1641
|
+
// TODO: Implement business logic
|
|
1642
|
+
// 1. Call external API via query factory
|
|
1643
|
+
// 2. Transform response if needed
|
|
1644
|
+
// 3. Handle errors
|
|
1645
|
+
|
|
1646
|
+
const response = await this.${featureNameCamel}Query(${pathParams.join(", ")});
|
|
1647
|
+
|
|
1648
|
+
if (response.status !== 200 && response.status !== 204) {
|
|
1649
|
+
// TODO: Handle error response
|
|
1650
|
+
throw new Error(\`External API error: \${response.status}\`);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
`;
|
|
1655
|
+
return {
|
|
1656
|
+
path: `service/${featureNameCamel}.service.ts`,
|
|
1657
|
+
content,
|
|
1658
|
+
isComplete: false
|
|
1659
|
+
// Has TODOs
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
function extractPathParams2(path) {
|
|
1663
|
+
const matches = path.match(/:(\w+)/g);
|
|
1664
|
+
if (!matches) return [];
|
|
1665
|
+
return matches.map((m) => m.slice(1));
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// src/lib/contracts/generators/query-factory.generator.ts
|
|
1669
|
+
function generateQueryFactory(ctx) {
|
|
1670
|
+
const {
|
|
1671
|
+
featureName,
|
|
1672
|
+
featureNameCamel,
|
|
1673
|
+
featureNameUpperSnake,
|
|
1674
|
+
apiServices,
|
|
1675
|
+
endpoint
|
|
1676
|
+
} = ctx;
|
|
1677
|
+
const primaryService = apiServices[0];
|
|
1678
|
+
const config = primaryService ? API_SERVICE_CONFIG[primaryService] : null;
|
|
1679
|
+
const baseUrlVar = config?.configReturn.baseUrl ?? "baseUrl";
|
|
1680
|
+
const apiKeyVar = config?.configReturn.apiKey ?? "apiKey";
|
|
1681
|
+
const pathParams = extractPathParams3(endpoint.path);
|
|
1682
|
+
const methodParams = pathParams.map((p) => `${p}: string`).join(", ");
|
|
1683
|
+
const content = `import {
|
|
1684
|
+
SESSION_REQUEST_HEADERS_WITH_AUTH,
|
|
1685
|
+
SessionRequestHeadersWithAuthFactory,
|
|
1686
|
+
} from "@/common/security/session/services/sessionUserRequest.provider";
|
|
1687
|
+
import { TSREST_INIT_CLIENT_SERVICE } from "@/common/ts-rest/tsrestclient.service";
|
|
1688
|
+
import { TsRestInitClient } from "@/common/ts-rest/types";
|
|
1689
|
+
|
|
1690
|
+
import { ${featureName}Config } from "../config/${featureNameCamel}.config";
|
|
1691
|
+
import { ${featureName}ExternalContract } from "../${featureNameCamel}.external.contract";
|
|
1692
|
+
|
|
1693
|
+
export const ${featureNameUpperSnake}_QUERY = "${featureNameUpperSnake}_QUERY";
|
|
1694
|
+
|
|
1695
|
+
export type ${featureName}Query = ReturnType<typeof create${featureName}QueryFactory>;
|
|
1696
|
+
|
|
1697
|
+
export const ${featureNameCamel}QueryProvider = {
|
|
1698
|
+
provide: ${featureNameUpperSnake}_QUERY,
|
|
1699
|
+
useFactory: create${featureName}QueryFactory,
|
|
1700
|
+
inject: [
|
|
1701
|
+
${featureName}Config,
|
|
1702
|
+
SESSION_REQUEST_HEADERS_WITH_AUTH,
|
|
1703
|
+
TSREST_INIT_CLIENT_SERVICE,
|
|
1704
|
+
],
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
export function create${featureName}QueryFactory(
|
|
1708
|
+
configService: ${featureName}Config,
|
|
1709
|
+
getRequestHeaders: SessionRequestHeadersWithAuthFactory,
|
|
1710
|
+
initClient: TsRestInitClient,
|
|
1711
|
+
) {
|
|
1712
|
+
function ${featureNameCamel}Query(${methodParams}) {
|
|
1713
|
+
const { ${baseUrlVar}, ${apiKeyVar} } = configService.get${featureName}Config();
|
|
1714
|
+
|
|
1715
|
+
const client = initClient(${featureName}ExternalContract, {
|
|
1716
|
+
baseUrl: ${baseUrlVar},
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
return client.${endpoint.name}({
|
|
1720
|
+
params: {
|
|
1721
|
+
// TODO: Map path params from contract to external API
|
|
1722
|
+
${pathParams.map((p) => `// ${p},`).join("\n ")}
|
|
1723
|
+
},
|
|
1724
|
+
body: {
|
|
1725
|
+
// TODO: Map request body if needed
|
|
1726
|
+
},
|
|
1727
|
+
headers: getRequestHeaders({ apiKey: ${apiKeyVar} }),
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
return ${featureNameCamel}Query;
|
|
1732
|
+
}
|
|
1733
|
+
`;
|
|
1734
|
+
return {
|
|
1735
|
+
path: `service/${featureNameCamel}Query.factory.ts`,
|
|
1736
|
+
content,
|
|
1737
|
+
isComplete: false
|
|
1738
|
+
// Has TODOs
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
function extractPathParams3(path) {
|
|
1742
|
+
const matches = path.match(/:(\w+)/g);
|
|
1743
|
+
if (!matches) return [];
|
|
1744
|
+
return matches.map((m) => m.slice(1));
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// src/lib/contracts/generators/external-contract.generator.ts
|
|
1748
|
+
function generateExternalContract(ctx) {
|
|
1749
|
+
const { featureName, featureNameCamel, apiServices, endpoint } = ctx;
|
|
1750
|
+
const apiComment = apiServices.length > 0 ? `// API: ${apiServices.join(", ")} (see config/${featureNameCamel}.config.ts for reference)` : "// API: (not specified in metadata)";
|
|
1751
|
+
const content = `// TODO: Define external API contract
|
|
1752
|
+
${apiComment}
|
|
1753
|
+
// Reference: src/domains/serviceExecution/skipContract/skipContract.external.contract.ts
|
|
1754
|
+
|
|
1755
|
+
import { initContract } from "@ts-rest/core";
|
|
1756
|
+
import { z } from "zod";
|
|
1757
|
+
import { Headers } from "@/common/contants/headers.contants";
|
|
1758
|
+
|
|
1759
|
+
const c = initContract();
|
|
1760
|
+
|
|
1761
|
+
export const ${featureName}ExternalContract = c.router({
|
|
1762
|
+
${endpoint.name}: {
|
|
1763
|
+
method: "${endpoint.method}",
|
|
1764
|
+
path: "/v1/...", // TODO: External API path
|
|
1765
|
+
pathParams: z.object({
|
|
1766
|
+
// TODO: Define path params for external API
|
|
1767
|
+
}),
|
|
1768
|
+
headers: z.object({
|
|
1769
|
+
[Headers.AUTHORIZATION]: z.string(),
|
|
1770
|
+
[Headers.X_GATEWAY_APIKEY]: z.string(),
|
|
1771
|
+
[Headers.X_BU_CODE]: z.string(),
|
|
1772
|
+
}),
|
|
1773
|
+
body: z.object({
|
|
1774
|
+
// TODO: External API body schema
|
|
1775
|
+
}),
|
|
1776
|
+
responses: {
|
|
1777
|
+
200: z.object({
|
|
1778
|
+
// TODO: Success response schema
|
|
1779
|
+
}),
|
|
1780
|
+
400: z.object({
|
|
1781
|
+
status: z.number(),
|
|
1782
|
+
message: z.string().nullable(),
|
|
1783
|
+
}),
|
|
1784
|
+
},
|
|
1785
|
+
},
|
|
1786
|
+
});
|
|
1787
|
+
`;
|
|
1788
|
+
return {
|
|
1789
|
+
path: `${featureNameCamel}.external.contract.ts`,
|
|
1790
|
+
content,
|
|
1791
|
+
isComplete: false
|
|
1792
|
+
// Has TODOs
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// src/lib/contracts/generators/e2e-test.generator.ts
|
|
1797
|
+
function generateE2eTest(ctx) {
|
|
1798
|
+
const {
|
|
1799
|
+
featureName,
|
|
1800
|
+
featureNameCamel,
|
|
1801
|
+
apiServices,
|
|
1802
|
+
endpoint,
|
|
1803
|
+
permissionConstant,
|
|
1804
|
+
successStatus
|
|
1805
|
+
} = ctx;
|
|
1806
|
+
const primaryService = apiServices[0];
|
|
1807
|
+
const config = primaryService ? API_SERVICE_CONFIG[primaryService] : null;
|
|
1808
|
+
const mockBaseUrl = config && primaryService ? `https://mock-${primaryService.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "")}-api` : "https://mock-api";
|
|
1809
|
+
const pathParams = extractPathParams4(endpoint.path);
|
|
1810
|
+
const testPath = endpoint.path.replace(
|
|
1811
|
+
/:(\w+)/g,
|
|
1812
|
+
(_, param) => `\${${param.toUpperCase()}}`
|
|
1813
|
+
);
|
|
1814
|
+
const fixtureConstants = pathParams.map(
|
|
1815
|
+
(p) => ` const ${p.toUpperCase()} = "12345"; // TODO: Use appropriate test value`
|
|
1816
|
+
).join("\n");
|
|
1817
|
+
const permissionCamel = permissionConstant.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
1818
|
+
const content = `import { INestApplication } from "@nestjs/common";
|
|
1819
|
+
import { JwtService } from "@nestjs/jwt";
|
|
1820
|
+
import {
|
|
1821
|
+
TEST_ROLES,
|
|
1822
|
+
initServerApp,
|
|
1823
|
+
mockExpiredToken,
|
|
1824
|
+
mockToken,
|
|
1825
|
+
} from "src/test/mock/mock.utils";
|
|
1826
|
+
import request from "supertest";
|
|
1827
|
+
import {
|
|
1828
|
+
afterAll,
|
|
1829
|
+
beforeAll,
|
|
1830
|
+
beforeEach,
|
|
1831
|
+
describe,
|
|
1832
|
+
expect,
|
|
1833
|
+
it,
|
|
1834
|
+
vi,
|
|
1835
|
+
} from "vitest";
|
|
1836
|
+
|
|
1837
|
+
import { Headers } from "@/common/contants/headers.contants";
|
|
1838
|
+
import { BACK_PARAMETERS } from "@/test/mock/backParameters";
|
|
1839
|
+
import { getCookieHeader } from "@/test/utils/test.utils";
|
|
1840
|
+
|
|
1841
|
+
describe("${featureName}Controller (e2e)", () => {
|
|
1842
|
+
let app: INestApplication;
|
|
1843
|
+
let jwtService: JwtService;
|
|
1844
|
+
let mock${featureName}Fn: ReturnType<typeof vi.fn>;
|
|
1845
|
+
let mockInitClient: ReturnType<typeof vi.fn>;
|
|
1846
|
+
let mockGetExternalParametersFn: ReturnType<typeof vi.fn>;
|
|
1847
|
+
|
|
1848
|
+
${fixtureConstants}
|
|
1849
|
+
|
|
1850
|
+
beforeAll(async () => {
|
|
1851
|
+
mock${featureName}Fn = vi.fn();
|
|
1852
|
+
mockGetExternalParametersFn = vi.fn();
|
|
1853
|
+
mockGetExternalParametersFn.mockResolvedValue(BACK_PARAMETERS);
|
|
1854
|
+
|
|
1855
|
+
mockInitClient = vi.fn().mockReturnValue({
|
|
1856
|
+
${endpoint.name}: mock${featureName}Fn,
|
|
1857
|
+
getExternalParameters: mockGetExternalParametersFn,
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
app = await initServerApp(mockInitClient);
|
|
1861
|
+
jwtService = app.get(JwtService);
|
|
1862
|
+
await app.listen(0);
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
afterAll(async () => {
|
|
1866
|
+
await app.close();
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
beforeEach(() => {
|
|
1870
|
+
mock${featureName}Fn.mockReset();
|
|
1871
|
+
mockInitClient.mockClear();
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
const verifyApiHeaders = () => {
|
|
1875
|
+
expect(mockInitClient).toHaveBeenCalled();
|
|
1876
|
+
expect(mockInitClient.mock.calls[0][1].baseUrl).toBe("${mockBaseUrl}");
|
|
1877
|
+
expect(mock${featureName}Fn).toHaveBeenCalled();
|
|
1878
|
+
expect(
|
|
1879
|
+
mock${featureName}Fn.mock.calls[0][0].headers[Headers.X_GATEWAY_APIKEY],
|
|
1880
|
+
).toBe("see"); // TODO: Verify expected API key
|
|
1881
|
+
expect(
|
|
1882
|
+
mock${featureName}Fn.mock.calls[0][0].headers[Headers.AUTHORIZATION],
|
|
1883
|
+
).toBe("Bearer token");
|
|
1884
|
+
expect(mock${featureName}Fn.mock.calls[0][0].headers[Headers.X_BU_CODE]).toBe(
|
|
1885
|
+
"LMES",
|
|
1886
|
+
);
|
|
1887
|
+
};
|
|
1888
|
+
|
|
1889
|
+
describe.each(TEST_ROLES)(
|
|
1890
|
+
"User with $role role",
|
|
1891
|
+
({ roleValue, ${permissionCamel} }) => {
|
|
1892
|
+
it("${endpoint.method} Valid request: ${successStatus}", async () => {
|
|
1893
|
+
mockToken(jwtService, roleValue);
|
|
1894
|
+
|
|
1895
|
+
if (${permissionCamel}) {
|
|
1896
|
+
mock${featureName}Fn.mockResolvedValue({ status: ${successStatus} });
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
const response = await request(app.getHttpServer())
|
|
1900
|
+
.${endpoint.method.toLowerCase()}(\`${testPath}\`)
|
|
1901
|
+
.send({}) // TODO: Add valid request body
|
|
1902
|
+
.set("x-kobi-authentication-cookie-name", "ahs-operator-portal.jwt")
|
|
1903
|
+
.set("cookie", getCookieHeader("token", "ES"));
|
|
1904
|
+
|
|
1905
|
+
expect(response.status).toBe(${permissionCamel} ? ${successStatus} : 403);
|
|
1906
|
+
|
|
1907
|
+
if (${permissionCamel}) {
|
|
1908
|
+
expect(mock${featureName}Fn).toHaveBeenCalled();
|
|
1909
|
+
// TODO: Verify body transformation
|
|
1910
|
+
verifyApiHeaders();
|
|
1911
|
+
} else {
|
|
1912
|
+
expect(mock${featureName}Fn).not.toHaveBeenCalled();
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
it("${endpoint.method} Invalid request: 400", async () => {
|
|
1917
|
+
mockToken(jwtService, roleValue);
|
|
1918
|
+
if (${permissionCamel}) {
|
|
1919
|
+
mock${featureName}Fn.mockResolvedValue({ status: 400 });
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
const response = await request(app.getHttpServer())
|
|
1923
|
+
.${endpoint.method.toLowerCase()}(\`${testPath}\`)
|
|
1924
|
+
.send({}) // TODO: Add invalid request body
|
|
1925
|
+
.set("x-kobi-authentication-cookie-name", "ahs-operator-portal.jwt")
|
|
1926
|
+
.set("cookie", getCookieHeader("token", "ES"));
|
|
1927
|
+
|
|
1928
|
+
if (${permissionCamel}) {
|
|
1929
|
+
expect(response.status).toBe(400);
|
|
1930
|
+
expect(mock${featureName}Fn).toHaveBeenCalled();
|
|
1931
|
+
} else {
|
|
1932
|
+
expect(response.status).toBe(403);
|
|
1933
|
+
expect(mock${featureName}Fn).not.toHaveBeenCalled();
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
it("${endpoint.method} Unauthorized: 401", async () => {
|
|
1938
|
+
mockExpiredToken(jwtService);
|
|
1939
|
+
|
|
1940
|
+
const response = await request(app.getHttpServer())
|
|
1941
|
+
.${endpoint.method.toLowerCase()}(\`${testPath}\`)
|
|
1942
|
+
.send({})
|
|
1943
|
+
.set("x-kobi-authentication-cookie-name", "ahs-operator-execution.jwt")
|
|
1944
|
+
.set("cookie", getCookieHeader("token", "ES"));
|
|
1945
|
+
|
|
1946
|
+
expect(response.status).toBe(401);
|
|
1947
|
+
expect(mock${featureName}Fn).not.toHaveBeenCalled();
|
|
1948
|
+
});
|
|
1949
|
+
},
|
|
1950
|
+
);
|
|
1951
|
+
});
|
|
1952
|
+
`;
|
|
1953
|
+
return {
|
|
1954
|
+
path: `controller/__tests__/${featureNameCamel}.controller.e2e.spec.ts`,
|
|
1955
|
+
content,
|
|
1956
|
+
isComplete: false
|
|
1957
|
+
// Has TODOs
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
function extractPathParams4(path) {
|
|
1961
|
+
const matches = path.match(/:(\w+)/g);
|
|
1962
|
+
if (!matches) return [];
|
|
1963
|
+
return matches.map((m) => m.slice(1));
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// src/lib/contracts/generators/types.generator.ts
|
|
1967
|
+
function generateTypes(ctx) {
|
|
1968
|
+
const { featureName, featureNameCamel } = ctx;
|
|
1969
|
+
const content = `// TODO: Define types specific to ${featureName}
|
|
1970
|
+
// These types are for internal use within this feature
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Request payload after transformation (if different from contract)
|
|
1974
|
+
*/
|
|
1975
|
+
export interface ${featureName}Request {
|
|
1976
|
+
// TODO: Define internal request type
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/**
|
|
1980
|
+
* Response type from external API (if different from contract response)
|
|
1981
|
+
*/
|
|
1982
|
+
export interface ${featureName}ExternalResponse {
|
|
1983
|
+
// TODO: Define external API response type
|
|
1984
|
+
}
|
|
1985
|
+
`;
|
|
1986
|
+
return {
|
|
1987
|
+
path: `${featureNameCamel}.types.ts`,
|
|
1988
|
+
content,
|
|
1989
|
+
isComplete: false
|
|
1990
|
+
// Has TODOs
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// src/lib/contracts/generators/index.ts
|
|
1995
|
+
function generateNestJsFeature(endpoint, options) {
|
|
1996
|
+
const errors = [];
|
|
1997
|
+
const baseName = options.featureName ?? endpoint.name;
|
|
1998
|
+
const featureName = toCase(baseName, "pascal");
|
|
1999
|
+
const featureNameCamel = toCase(baseName, "camel");
|
|
2000
|
+
const featureNameUpperSnake = toCase(baseName, "snake");
|
|
2001
|
+
const featureNameKebab = toCase(baseName, "kebab");
|
|
2002
|
+
const apiServices = endpoint.metadata?.bff?.apiServices ?? [];
|
|
2003
|
+
const permissionValue = endpoint.metadata?.requiredPermission ?? "NO_PERMISSION";
|
|
2004
|
+
const permissionConstant = toPermissionConstant(permissionValue);
|
|
2005
|
+
const ctx = {
|
|
2006
|
+
featureName,
|
|
2007
|
+
featureNameCamel,
|
|
2008
|
+
featureNameUpperSnake,
|
|
2009
|
+
featureNameKebab,
|
|
2010
|
+
contractName: options.contractName,
|
|
2011
|
+
contractsLib: options.contractsLib,
|
|
2012
|
+
endpoint,
|
|
2013
|
+
apiServices,
|
|
2014
|
+
permissionConstant,
|
|
2015
|
+
successStatus: getSuccessStatus(void 0)
|
|
2016
|
+
// TODO: pass responses from endpoint
|
|
2017
|
+
};
|
|
2018
|
+
const files = [
|
|
2019
|
+
generateModule(ctx),
|
|
2020
|
+
generateController(ctx),
|
|
2021
|
+
generateConfig(ctx),
|
|
2022
|
+
generateService(ctx),
|
|
2023
|
+
generateQueryFactory(ctx),
|
|
2024
|
+
generateExternalContract(ctx),
|
|
2025
|
+
generateE2eTest(ctx),
|
|
2026
|
+
generateTypes(ctx)
|
|
2027
|
+
];
|
|
2028
|
+
return { files, errors };
|
|
2029
|
+
}
|
|
2030
|
+
function summarizeGeneration(result) {
|
|
2031
|
+
const complete = result.files.filter((f) => f.isComplete);
|
|
2032
|
+
const skeleton = result.files.filter((f) => !f.isComplete);
|
|
2033
|
+
const lines = [];
|
|
2034
|
+
if (complete.length > 0) {
|
|
2035
|
+
lines.push(`\u2705 Complete files (${complete.length}):`);
|
|
2036
|
+
for (const file of complete) {
|
|
2037
|
+
lines.push(` - ${file.path}`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
if (skeleton.length > 0) {
|
|
2041
|
+
lines.push(`\u{1F4DD} Skeleton files with TODOs (${skeleton.length}):`);
|
|
2042
|
+
for (const file of skeleton) {
|
|
2043
|
+
lines.push(` - ${file.path}`);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
if (result.errors.length > 0) {
|
|
2047
|
+
lines.push(`\u274C Errors (${result.errors.length}):`);
|
|
2048
|
+
for (const error of result.errors) {
|
|
2049
|
+
lines.push(` - ${error}`);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
return lines.join("\n");
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// src/lint/checkers/contracts/index.ts
|
|
2056
|
+
var contractsChecker = {
|
|
2057
|
+
name: "contracts",
|
|
2058
|
+
rules: getRulesByCategory("contracts"),
|
|
2059
|
+
async check(ctx) {
|
|
2060
|
+
const results = [];
|
|
2061
|
+
const configResult = loadConfig(ctx.cwd);
|
|
2062
|
+
if (!configResult.success) {
|
|
2063
|
+
return [];
|
|
2064
|
+
}
|
|
2065
|
+
const config = configResult.config;
|
|
2066
|
+
if (config.project.type !== "contracts-lib") {
|
|
2067
|
+
return [];
|
|
2068
|
+
}
|
|
2069
|
+
const permissionsConfig = getPermissionsConfig(config);
|
|
2070
|
+
let validPermissions = [];
|
|
2071
|
+
let permissionKeyToValue;
|
|
2072
|
+
if (permissionsConfig) {
|
|
2073
|
+
const sourcePath = resolveConfigPath(ctx.cwd, permissionsConfig.source);
|
|
2074
|
+
const permissionsResult = extractValues(sourcePath, permissionsConfig);
|
|
2075
|
+
if (permissionsResult.success) {
|
|
2076
|
+
validPermissions = permissionsResult.values;
|
|
2077
|
+
permissionKeyToValue = permissionsResult.keyToValue;
|
|
2078
|
+
} else {
|
|
2079
|
+
results.push({
|
|
2080
|
+
ruleId: "CTR-005",
|
|
2081
|
+
severity: "warning",
|
|
2082
|
+
message: `Failed to load permissions: ${permissionsResult.error}`,
|
|
2083
|
+
file: permissionsConfig.source,
|
|
2084
|
+
suggestion: "Check lint.contracts.metadata.permissions config in .hexa-ts-kit.yaml"
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
const apiServicesConfig = getApiServicesConfig(config);
|
|
2089
|
+
let validApiServices = [];
|
|
2090
|
+
if (apiServicesConfig) {
|
|
2091
|
+
const sourcePath = resolveConfigPath(ctx.cwd, apiServicesConfig.source);
|
|
2092
|
+
const apiServicesResult = extractValues(sourcePath, apiServicesConfig);
|
|
2093
|
+
if (apiServicesResult.success) {
|
|
2094
|
+
validApiServices = apiServicesResult.values;
|
|
2095
|
+
} else {
|
|
2096
|
+
results.push({
|
|
2097
|
+
ruleId: "CTR-006",
|
|
2098
|
+
severity: "warning",
|
|
2099
|
+
message: `Failed to load apiServices: ${apiServicesResult.error}`,
|
|
2100
|
+
file: apiServicesConfig.source,
|
|
2101
|
+
suggestion: "Check lint.contracts.metadata.apiServices config in .hexa-ts-kit.yaml"
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
const parseResult = await parseContracts(ctx.cwd);
|
|
2106
|
+
if (!parseResult.success) {
|
|
2107
|
+
results.push({
|
|
2108
|
+
ruleId: "CTR-001",
|
|
2109
|
+
severity: "error",
|
|
2110
|
+
message: `Failed to parse contracts: ${parseResult.error}`,
|
|
2111
|
+
file: ctx.cwd
|
|
2112
|
+
});
|
|
2113
|
+
return results;
|
|
2114
|
+
}
|
|
2115
|
+
const disabledRules = getDisabledRules(config);
|
|
2116
|
+
const validationResult = validateContracts(parseResult.contracts, {
|
|
2117
|
+
validPermissions,
|
|
2118
|
+
validApiServices,
|
|
2119
|
+
permissionKeyToValue
|
|
2120
|
+
});
|
|
2121
|
+
for (const issue of validationResult.issues) {
|
|
2122
|
+
if (disabledRules.includes(issue.rule)) {
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
results.push({
|
|
2126
|
+
ruleId: issue.rule,
|
|
2127
|
+
severity: issue.severity,
|
|
2128
|
+
message: issue.message,
|
|
2129
|
+
file: issue.filePath,
|
|
2130
|
+
line: issue.line,
|
|
2131
|
+
suggestion: issue.suggestion
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
return results;
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
|
|
2138
|
+
// src/lint/reporters/console.ts
|
|
2139
|
+
import pc from "picocolors";
|
|
2140
|
+
var severityColors = {
|
|
2141
|
+
error: pc.red,
|
|
2142
|
+
warning: pc.yellow,
|
|
2143
|
+
info: pc.blue
|
|
2144
|
+
};
|
|
2145
|
+
var severityIcons = {
|
|
2146
|
+
error: "\u2716",
|
|
2147
|
+
warning: "\u26A0",
|
|
2148
|
+
info: "\u2139"
|
|
2149
|
+
};
|
|
2150
|
+
function formatConsole(results) {
|
|
2151
|
+
if (results.length === 0) {
|
|
2152
|
+
return pc.green("\u2713 No issues found");
|
|
2153
|
+
}
|
|
2154
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2155
|
+
for (const result of results) {
|
|
2156
|
+
const existing = byFile.get(result.file) ?? [];
|
|
2157
|
+
existing.push(result);
|
|
2158
|
+
byFile.set(result.file, existing);
|
|
2159
|
+
}
|
|
2160
|
+
const lines = [];
|
|
2161
|
+
for (const [file, fileResults] of byFile) {
|
|
2162
|
+
lines.push("");
|
|
2163
|
+
lines.push(pc.underline(file));
|
|
2164
|
+
for (const result of fileResults) {
|
|
2165
|
+
const color = severityColors[result.severity];
|
|
2166
|
+
const icon = severityIcons[result.severity];
|
|
2167
|
+
const location = result.line !== void 0 ? `:${result.line}:${result.column ?? 0}` : "";
|
|
2168
|
+
lines.push(
|
|
2169
|
+
` ${color(icon)} ${result.message} ${pc.dim(`[${result.ruleId}]`)}`
|
|
2170
|
+
);
|
|
2171
|
+
if (result.suggestion) {
|
|
2172
|
+
lines.push(` ${pc.dim("\u2192")} ${pc.cyan(result.suggestion)}`);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
const errorCount = results.filter((r) => r.severity === "error").length;
|
|
2177
|
+
const warningCount = results.filter((r) => r.severity === "warning").length;
|
|
2178
|
+
const infoCount = results.filter((r) => r.severity === "info").length;
|
|
2179
|
+
lines.push("");
|
|
2180
|
+
lines.push(
|
|
2181
|
+
pc.bold(
|
|
2182
|
+
`${pc.red(`${errorCount} error${errorCount !== 1 ? "s" : ""}`)}, ${pc.yellow(`${warningCount} warning${warningCount !== 1 ? "s" : ""}`)}, ${pc.blue(`${infoCount} info`)}`
|
|
2183
|
+
)
|
|
2184
|
+
);
|
|
2185
|
+
return lines.join("\n");
|
|
2186
|
+
}
|
|
2187
|
+
function formatSummary(results) {
|
|
2188
|
+
return {
|
|
2189
|
+
errors: results.filter((r) => r.severity === "error").length,
|
|
2190
|
+
warnings: results.filter((r) => r.severity === "warning").length,
|
|
2191
|
+
info: results.filter((r) => r.severity === "info").length,
|
|
2192
|
+
total: results.length
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// src/commands/lint.ts
|
|
2197
|
+
var contractsLibCheckers = [contractsChecker];
|
|
2198
|
+
function detectProjectType(cwd) {
|
|
2199
|
+
const configResult = loadConfig(cwd);
|
|
2200
|
+
if (!configResult.success) {
|
|
2201
|
+
return {
|
|
2202
|
+
type: null,
|
|
2203
|
+
checkers: [],
|
|
2204
|
+
supported: false,
|
|
2205
|
+
message: "No .hexa-ts-kit.yaml found. Skipping lint."
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
const projectType = configResult.config.project.type;
|
|
2209
|
+
switch (projectType) {
|
|
2210
|
+
case "contracts-lib":
|
|
2211
|
+
return {
|
|
2212
|
+
type: "contracts-lib",
|
|
2213
|
+
checkers: contractsLibCheckers,
|
|
2214
|
+
supported: true
|
|
2215
|
+
};
|
|
2216
|
+
case "vue-frontend":
|
|
2217
|
+
return {
|
|
2218
|
+
type: "vue-frontend",
|
|
2219
|
+
checkers: [],
|
|
2220
|
+
supported: false,
|
|
2221
|
+
message: "vue-frontend lint not yet implemented. Skipping. (Coming soon)"
|
|
2222
|
+
};
|
|
2223
|
+
case "nestjs-bff":
|
|
2224
|
+
return {
|
|
2225
|
+
type: "nestjs-bff",
|
|
2226
|
+
checkers: [],
|
|
2227
|
+
supported: false,
|
|
2228
|
+
message: "nestjs-bff lint not yet implemented. Skipping. (Coming soon)"
|
|
2229
|
+
};
|
|
2230
|
+
default:
|
|
2231
|
+
return {
|
|
2232
|
+
type: null,
|
|
2233
|
+
checkers: [],
|
|
2234
|
+
supported: false,
|
|
2235
|
+
message: `Unknown project type: ${projectType}. Valid types: contracts-lib, vue-frontend, nestjs-bff`
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
function getChangedFiles(cwd) {
|
|
2240
|
+
try {
|
|
2241
|
+
const output = execSync("git diff --name-only HEAD", {
|
|
2242
|
+
cwd,
|
|
2243
|
+
encoding: "utf-8"
|
|
2244
|
+
});
|
|
2245
|
+
const stagedOutput = execSync("git diff --name-only --cached", {
|
|
2246
|
+
cwd,
|
|
2247
|
+
encoding: "utf-8"
|
|
2248
|
+
});
|
|
2249
|
+
const allFiles = [...output.split("\n"), ...stagedOutput.split("\n")].filter(Boolean).filter((f) => f.endsWith(".ts") || f.endsWith(".vue"));
|
|
2250
|
+
return [...new Set(allFiles)];
|
|
2251
|
+
} catch {
|
|
2252
|
+
return [];
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
async function lintCore(options) {
|
|
2256
|
+
const cwd = resolve2(options.path || ".");
|
|
2257
|
+
const projectConfig = detectProjectType(cwd);
|
|
2258
|
+
if (!projectConfig.supported) {
|
|
2259
|
+
return {
|
|
2260
|
+
success: true,
|
|
2261
|
+
// Not an error, just not supported yet
|
|
2262
|
+
command: "lint",
|
|
2263
|
+
projectType: projectConfig.type,
|
|
2264
|
+
message: projectConfig.message,
|
|
2265
|
+
results: [],
|
|
2266
|
+
summary: { errors: 0, warnings: 0, info: 0, total: 0 }
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
let files = [];
|
|
2270
|
+
if (options.changed) {
|
|
2271
|
+
files = getChangedFiles(cwd);
|
|
2272
|
+
if (files.length === 0) {
|
|
2273
|
+
return {
|
|
2274
|
+
success: true,
|
|
2275
|
+
command: "lint",
|
|
2276
|
+
projectType: projectConfig.type,
|
|
2277
|
+
message: "No changed files to lint",
|
|
2278
|
+
files: [],
|
|
2279
|
+
results: [],
|
|
2280
|
+
summary: { errors: 0, warnings: 0, info: 0, total: 0 }
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
let checkersToRun = projectConfig.checkers;
|
|
2285
|
+
if (options.rules) {
|
|
2286
|
+
const prefixes = options.rules.split(",").map((r) => r.trim().toUpperCase());
|
|
2287
|
+
checkersToRun = checkersToRun.filter(
|
|
2288
|
+
(checker) => checker.rules.some(
|
|
2289
|
+
(rule) => prefixes.some((p) => rule.startsWith(p))
|
|
2290
|
+
)
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
const allResults = [];
|
|
2294
|
+
for (const checker of checkersToRun) {
|
|
2295
|
+
const results = await checker.check({ cwd, files });
|
|
2296
|
+
if (options.changed && files.length > 0) {
|
|
2297
|
+
const filteredResults2 = results.filter(
|
|
2298
|
+
(r) => files.some((f) => r.file.endsWith(f))
|
|
2299
|
+
);
|
|
2300
|
+
allResults.push(...filteredResults2);
|
|
2301
|
+
} else {
|
|
2302
|
+
allResults.push(...results);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const filteredResults = options.quiet ? allResults.filter((r) => r.severity === "error") : allResults;
|
|
2306
|
+
const summary = formatSummary(allResults);
|
|
2307
|
+
return {
|
|
2308
|
+
success: summary.errors === 0,
|
|
2309
|
+
command: "lint",
|
|
2310
|
+
projectType: projectConfig.type,
|
|
2311
|
+
files: options.changed ? files : void 0,
|
|
2312
|
+
results: filteredResults,
|
|
2313
|
+
summary
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
async function lintCommand(path = ".", options) {
|
|
2317
|
+
const startTime = performance.now();
|
|
2318
|
+
const targetPath = options.cwd || path;
|
|
2319
|
+
if (options.debug) {
|
|
2320
|
+
console.log(`Linting: ${resolve2(targetPath)}`);
|
|
2321
|
+
const projectConfig = detectProjectType(resolve2(targetPath));
|
|
2322
|
+
console.log(`Project type: ${projectConfig.type ?? "not configured"}`);
|
|
2323
|
+
console.log(
|
|
2324
|
+
`Checkers: ${projectConfig.checkers.map((c) => c.name).join(", ") || "none"}`
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
const result = await lintCore({
|
|
2328
|
+
path: targetPath,
|
|
2329
|
+
changed: options.changed,
|
|
2330
|
+
rules: options.rules,
|
|
2331
|
+
quiet: options.quiet
|
|
2332
|
+
});
|
|
2333
|
+
if (options.format === "json") {
|
|
2334
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2335
|
+
} else {
|
|
2336
|
+
if (result.message) {
|
|
2337
|
+
console.log(result.message);
|
|
2338
|
+
} else {
|
|
2339
|
+
console.log(formatConsole(result.results));
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
if (options.debug) {
|
|
2343
|
+
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
2344
|
+
console.log(`
|
|
2345
|
+
Completed in ${elapsed}ms`);
|
|
2346
|
+
}
|
|
2347
|
+
process.exit(result.summary.errors > 0 ? 1 : 0);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// src/commands/analyze.ts
|
|
2351
|
+
import { basename as basename4 } from "path";
|
|
2352
|
+
import { execSync as execSync2 } from "child_process";
|
|
2353
|
+
import { readFileSync as readFileSync4, existsSync as existsSync5 } from "fs";
|
|
2354
|
+
import fg8 from "fast-glob";
|
|
2355
|
+
import matter from "gray-matter";
|
|
2356
|
+
import { minimatch } from "minimatch";
|
|
2357
|
+
function expandPath(p) {
|
|
2358
|
+
if (p.startsWith("~")) {
|
|
2359
|
+
return p.replace("~", process.env.HOME || "");
|
|
2360
|
+
}
|
|
2361
|
+
return p;
|
|
2362
|
+
}
|
|
2363
|
+
function getChangedFiles2(cwd) {
|
|
2364
|
+
try {
|
|
2365
|
+
const output = execSync2("git diff --name-only HEAD", {
|
|
2366
|
+
cwd,
|
|
2367
|
+
encoding: "utf-8"
|
|
2368
|
+
});
|
|
2369
|
+
const stagedOutput = execSync2("git diff --name-only --cached", {
|
|
2370
|
+
cwd,
|
|
2371
|
+
encoding: "utf-8"
|
|
2372
|
+
});
|
|
2373
|
+
return [...output.split("\n"), ...stagedOutput.split("\n")].filter(Boolean).filter((f) => f.endsWith(".ts") || f.endsWith(".vue"));
|
|
2374
|
+
} catch {
|
|
2375
|
+
return [];
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
function loadKnowledgeMappings(knowledgePath) {
|
|
2379
|
+
const expandedPath = expandPath(knowledgePath);
|
|
2380
|
+
if (!existsSync5(expandedPath)) {
|
|
2381
|
+
return [];
|
|
2382
|
+
}
|
|
2383
|
+
const knowledgeFiles = fg8.sync("**/*.knowledge.md", {
|
|
2384
|
+
cwd: expandedPath,
|
|
2385
|
+
absolute: true
|
|
2386
|
+
});
|
|
2387
|
+
const mappings = [];
|
|
2388
|
+
for (const file of knowledgeFiles) {
|
|
2389
|
+
try {
|
|
2390
|
+
const content = readFileSync4(file, "utf-8");
|
|
2391
|
+
const { data } = matter(content);
|
|
2392
|
+
if (data.match) {
|
|
2393
|
+
mappings.push({
|
|
2394
|
+
name: data.name || basename4(file, ".knowledge.md"),
|
|
2395
|
+
path: file,
|
|
2396
|
+
match: data.match,
|
|
2397
|
+
description: data.description
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
} catch {
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
return mappings;
|
|
2404
|
+
}
|
|
2405
|
+
function matchFileToKnowledges(file, mappings) {
|
|
2406
|
+
const results = [];
|
|
2407
|
+
const fileName = basename4(file);
|
|
2408
|
+
for (const mapping of mappings) {
|
|
2409
|
+
if (minimatch(fileName, mapping.match) || minimatch(file, mapping.match)) {
|
|
2410
|
+
results.push({
|
|
2411
|
+
file,
|
|
2412
|
+
knowledge: mapping.name,
|
|
2413
|
+
path: mapping.path
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
return results;
|
|
2418
|
+
}
|
|
2419
|
+
var DEFAULT_KNOWLEDGE_PATH = "~/.claude/marketplace/shared/knowledge";
|
|
2420
|
+
async function analyzeCore(options) {
|
|
2421
|
+
const cwd = process.cwd();
|
|
2422
|
+
const knowledgePath = options.knowledgePath || DEFAULT_KNOWLEDGE_PATH;
|
|
2423
|
+
let filesToAnalyze = options.files || [];
|
|
2424
|
+
if (options.changed) {
|
|
2425
|
+
filesToAnalyze = getChangedFiles2(cwd);
|
|
2426
|
+
}
|
|
2427
|
+
if (filesToAnalyze.length === 0) {
|
|
2428
|
+
return {
|
|
2429
|
+
success: true,
|
|
2430
|
+
command: "analyze",
|
|
2431
|
+
message: "No files to analyze",
|
|
2432
|
+
files: [],
|
|
2433
|
+
knowledges: []
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
const mappings = loadKnowledgeMappings(knowledgePath);
|
|
2437
|
+
if (mappings.length === 0) {
|
|
2438
|
+
return {
|
|
2439
|
+
success: false,
|
|
2440
|
+
command: "analyze",
|
|
2441
|
+
error: `No knowledge files with 'match' pattern found in ${knowledgePath}`,
|
|
2442
|
+
files: filesToAnalyze,
|
|
2443
|
+
knowledges: []
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
const allResults = [];
|
|
2447
|
+
for (const file of filesToAnalyze) {
|
|
2448
|
+
const matches = matchFileToKnowledges(file, mappings);
|
|
2449
|
+
allResults.push(...matches);
|
|
2450
|
+
}
|
|
2451
|
+
return {
|
|
2452
|
+
success: true,
|
|
2453
|
+
command: "analyze",
|
|
2454
|
+
files: filesToAnalyze,
|
|
2455
|
+
knowledges: allResults,
|
|
2456
|
+
availableMappings: mappings.length
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
async function analyzeCommand(files = [], options) {
|
|
2460
|
+
const result = await analyzeCore({
|
|
2461
|
+
files,
|
|
2462
|
+
changed: options.changed,
|
|
2463
|
+
knowledgePath: options.knowledgePath
|
|
2464
|
+
});
|
|
2465
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// src/commands/scaffold.ts
|
|
2469
|
+
import { resolve as resolve3, dirname as dirname3, basename as basename5, join as join3 } from "path";
|
|
2470
|
+
import { mkdirSync, writeFileSync, existsSync as existsSync6 } from "fs";
|
|
2471
|
+
function toPascalCase(str) {
|
|
2472
|
+
return str.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
2473
|
+
}
|
|
2474
|
+
function toCamelCase(str) {
|
|
2475
|
+
const pascal = toPascalCase(str);
|
|
2476
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
2477
|
+
}
|
|
2478
|
+
function generateVueFeature(featurePath) {
|
|
2479
|
+
const featureName = basename5(featurePath);
|
|
2480
|
+
const pascalName = toPascalCase(featureName);
|
|
2481
|
+
const camelName = toCamelCase(featureName);
|
|
2482
|
+
return [
|
|
2483
|
+
{
|
|
2484
|
+
path: `${featurePath}/${pascalName}.vue`,
|
|
2485
|
+
content: `<script setup lang="ts">
|
|
2486
|
+
import { use${pascalName} } from './${camelName}.composable';
|
|
2487
|
+
|
|
2488
|
+
const { /* state and methods */ } = use${pascalName}();
|
|
2489
|
+
</script>
|
|
2490
|
+
|
|
2491
|
+
<template>
|
|
2492
|
+
<div class="${featureName}">
|
|
2493
|
+
<!-- Template -->
|
|
2494
|
+
</div>
|
|
2495
|
+
</template>
|
|
2496
|
+
`,
|
|
2497
|
+
knowledge: "vue-component",
|
|
2498
|
+
rules: ["COL-001", "VUE-001"]
|
|
2499
|
+
},
|
|
2500
|
+
{
|
|
2501
|
+
path: `${featurePath}/${camelName}.composable.ts`,
|
|
2502
|
+
content: `import { ref, computed } from 'vue';
|
|
2503
|
+
import { ${camelName}Rules } from './${camelName}.rules';
|
|
2504
|
+
import type { ${pascalName}State } from './${camelName}.types';
|
|
2505
|
+
|
|
2506
|
+
export function use${pascalName}() {
|
|
2507
|
+
// State
|
|
2508
|
+
|
|
2509
|
+
// Computed
|
|
2510
|
+
|
|
2511
|
+
// Methods
|
|
2512
|
+
|
|
2513
|
+
return {
|
|
2514
|
+
// Expose state and methods
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
`,
|
|
2518
|
+
knowledge: "vue-composable",
|
|
2519
|
+
rules: ["COL-002", "NAM-002", "NAM-003"]
|
|
2520
|
+
},
|
|
2521
|
+
{
|
|
2522
|
+
path: `${featurePath}/${camelName}.rules.ts`,
|
|
2523
|
+
content: `// Pure functions only - no framework imports, no side effects
|
|
2524
|
+
|
|
2525
|
+
export class ${pascalName}Rules {
|
|
2526
|
+
// Validation, calculation, transformation functions
|
|
2527
|
+
|
|
2528
|
+
static validate(input: unknown): boolean {
|
|
2529
|
+
// Implement validation logic
|
|
2530
|
+
return true;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
export const ${camelName}Rules = ${pascalName}Rules;
|
|
2535
|
+
`,
|
|
2536
|
+
knowledge: "vue-rules",
|
|
2537
|
+
rules: ["RUL-001", "RUL-002", "NAM-008"]
|
|
2538
|
+
},
|
|
2539
|
+
{
|
|
2540
|
+
path: `${featurePath}/${camelName}.types.ts`,
|
|
2541
|
+
content: `export interface ${pascalName}State {
|
|
2542
|
+
// Define state types
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
export interface ${pascalName}Props {
|
|
2546
|
+
// Define component props
|
|
2547
|
+
}
|
|
2548
|
+
`,
|
|
2549
|
+
knowledge: "typescript-types",
|
|
2550
|
+
rules: ["COL-010", "NAM-005"]
|
|
2551
|
+
},
|
|
2552
|
+
{
|
|
2553
|
+
path: `${featurePath}/${camelName}.query.ts`,
|
|
2554
|
+
content: `import { useQuery, useMutation } from '@tanstack/vue-query';
|
|
2555
|
+
|
|
2556
|
+
export function use${pascalName}Query() {
|
|
2557
|
+
// Implement TanStack Query hooks
|
|
2558
|
+
}
|
|
2559
|
+
`,
|
|
2560
|
+
knowledge: "tanstack-query",
|
|
2561
|
+
rules: ["NAM-007"]
|
|
2562
|
+
},
|
|
2563
|
+
{
|
|
2564
|
+
path: `${featurePath}/${camelName}.translations.ts`,
|
|
2565
|
+
content: `export const ${camelName}Translations = {
|
|
2566
|
+
fr: {
|
|
2567
|
+
// French translations
|
|
2568
|
+
},
|
|
2569
|
+
en: {
|
|
2570
|
+
// English translations
|
|
2571
|
+
},
|
|
2572
|
+
} as const;
|
|
2573
|
+
`,
|
|
2574
|
+
knowledge: "translations",
|
|
2575
|
+
rules: ["COL-011", "NAM-009"]
|
|
2576
|
+
},
|
|
2577
|
+
{
|
|
2578
|
+
path: `${featurePath}/__tests__/${camelName}.rules.test.ts`,
|
|
2579
|
+
content: `import { describe, it, expect } from 'vitest';
|
|
2580
|
+
import { ${pascalName}Rules } from '../${camelName}.rules';
|
|
2581
|
+
|
|
2582
|
+
describe('${pascalName}Rules', () => {
|
|
2583
|
+
describe('validate', () => {
|
|
2584
|
+
it('should validate correct input', () => {
|
|
2585
|
+
expect(${pascalName}Rules.validate({})).toBe(true);
|
|
2586
|
+
});
|
|
2587
|
+
});
|
|
2588
|
+
});
|
|
2589
|
+
`,
|
|
2590
|
+
knowledge: "testing-rules",
|
|
2591
|
+
rules: ["COL-004"]
|
|
2592
|
+
}
|
|
2593
|
+
];
|
|
2594
|
+
}
|
|
2595
|
+
function generateNestJSFeature(featurePath) {
|
|
2596
|
+
const featureName = basename5(featurePath);
|
|
2597
|
+
const pascalName = toPascalCase(featureName);
|
|
2598
|
+
const camelName = toCamelCase(featureName);
|
|
2599
|
+
return [
|
|
2600
|
+
{
|
|
2601
|
+
path: `${featurePath}/${camelName}.module.ts`,
|
|
2602
|
+
content: `import { Module } from '@nestjs/common';
|
|
2603
|
+
import { ${pascalName}Controller } from './${camelName}.controller';
|
|
2604
|
+
import { ${pascalName}Service } from './${camelName}.service';
|
|
2605
|
+
|
|
2606
|
+
@Module({
|
|
2607
|
+
controllers: [${pascalName}Controller],
|
|
2608
|
+
providers: [${pascalName}Service],
|
|
2609
|
+
exports: [${pascalName}Service],
|
|
2610
|
+
})
|
|
2611
|
+
export class ${pascalName}Module {}
|
|
2612
|
+
`,
|
|
2613
|
+
knowledge: "nestjs-module"
|
|
2614
|
+
},
|
|
2615
|
+
{
|
|
2616
|
+
path: `${featurePath}/${camelName}.controller.ts`,
|
|
2617
|
+
content: `import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
|
2618
|
+
import { ${pascalName}Service } from './${camelName}.service';
|
|
2619
|
+
|
|
2620
|
+
@Controller('${featureName}')
|
|
2621
|
+
export class ${pascalName}Controller {
|
|
2622
|
+
constructor(private readonly ${camelName}Service: ${pascalName}Service) {}
|
|
2623
|
+
|
|
2624
|
+
@Get()
|
|
2625
|
+
findAll() {
|
|
2626
|
+
return this.${camelName}Service.findAll();
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
`,
|
|
2630
|
+
knowledge: "nestjs-controller"
|
|
2631
|
+
},
|
|
2632
|
+
{
|
|
2633
|
+
path: `${featurePath}/${camelName}.service.ts`,
|
|
2634
|
+
content: `import { Injectable } from '@nestjs/common';
|
|
2635
|
+
|
|
2636
|
+
@Injectable()
|
|
2637
|
+
export class ${pascalName}Service {
|
|
2638
|
+
findAll() {
|
|
2639
|
+
// Implement service logic
|
|
2640
|
+
return [];
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
`,
|
|
2644
|
+
knowledge: "nestjs-service"
|
|
2645
|
+
},
|
|
2646
|
+
{
|
|
2647
|
+
path: `${featurePath}/${camelName}.types.ts`,
|
|
2648
|
+
content: `export interface ${pascalName}Entity {
|
|
2649
|
+
id: string;
|
|
2650
|
+
// Define entity properties
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
export interface Create${pascalName}Dto {
|
|
2654
|
+
// Define creation DTO
|
|
2655
|
+
}
|
|
2656
|
+
`,
|
|
2657
|
+
knowledge: "typescript-types"
|
|
2658
|
+
},
|
|
2659
|
+
{
|
|
2660
|
+
path: `${featurePath}/__tests__/${camelName}.controller.e2e.spec.ts`,
|
|
2661
|
+
content: `import { Test, TestingModule } from '@nestjs/testing';
|
|
2662
|
+
import { ${pascalName}Controller } from '../${camelName}.controller';
|
|
2663
|
+
import { ${pascalName}Service } from '../${camelName}.service';
|
|
2664
|
+
|
|
2665
|
+
describe('${pascalName}Controller', () => {
|
|
2666
|
+
let controller: ${pascalName}Controller;
|
|
2667
|
+
|
|
2668
|
+
beforeEach(async () => {
|
|
2669
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
2670
|
+
controllers: [${pascalName}Controller],
|
|
2671
|
+
providers: [${pascalName}Service],
|
|
2672
|
+
}).compile();
|
|
2673
|
+
|
|
2674
|
+
controller = module.get<${pascalName}Controller>(${pascalName}Controller);
|
|
2675
|
+
});
|
|
2676
|
+
|
|
2677
|
+
it('should be defined', () => {
|
|
2678
|
+
expect(controller).toBeDefined();
|
|
2679
|
+
});
|
|
2680
|
+
});
|
|
2681
|
+
`,
|
|
2682
|
+
knowledge: "nestjs-testing"
|
|
2683
|
+
}
|
|
2684
|
+
];
|
|
2685
|
+
}
|
|
2686
|
+
function generatePlaywrightFeature(featurePath) {
|
|
2687
|
+
const featureName = basename5(featurePath);
|
|
2688
|
+
const pascalName = toPascalCase(featureName);
|
|
2689
|
+
const camelName = toCamelCase(featureName);
|
|
2690
|
+
return [
|
|
2691
|
+
{
|
|
2692
|
+
path: `${featurePath}/${camelName}.spec.ts`,
|
|
2693
|
+
content: `import { test, expect } from '@playwright/test';
|
|
2694
|
+
import { ${pascalName}Page } from './${camelName}.page';
|
|
2695
|
+
|
|
2696
|
+
test.describe('${pascalName}', () => {
|
|
2697
|
+
test('should load correctly', async ({ page }) => {
|
|
2698
|
+
const ${camelName}Page = new ${pascalName}Page(page);
|
|
2699
|
+
await ${camelName}Page.goto();
|
|
2700
|
+
// Add assertions
|
|
2701
|
+
});
|
|
2702
|
+
});
|
|
2703
|
+
`,
|
|
2704
|
+
knowledge: "playwright-spec"
|
|
2705
|
+
},
|
|
2706
|
+
{
|
|
2707
|
+
path: `${featurePath}/${camelName}.page.ts`,
|
|
2708
|
+
content: `import type { Page, Locator } from '@playwright/test';
|
|
2709
|
+
|
|
2710
|
+
export class ${pascalName}Page {
|
|
2711
|
+
readonly page: Page;
|
|
2712
|
+
|
|
2713
|
+
constructor(page: Page) {
|
|
2714
|
+
this.page = page;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
async goto() {
|
|
2718
|
+
await this.page.goto('/${featureName}');
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// Add page object methods
|
|
2722
|
+
}
|
|
2723
|
+
`,
|
|
2724
|
+
knowledge: "playwright-page-object"
|
|
2725
|
+
},
|
|
2726
|
+
{
|
|
2727
|
+
path: `${featurePath}/${camelName}.fixtures.ts`,
|
|
2728
|
+
content: `import { test as base } from '@playwright/test';
|
|
2729
|
+
import { ${pascalName}Page } from './${camelName}.page';
|
|
2730
|
+
|
|
2731
|
+
export const test = base.extend<{ ${camelName}Page: ${pascalName}Page }>({
|
|
2732
|
+
${camelName}Page: async ({ page }, use) => {
|
|
2733
|
+
const ${camelName}Page = new ${pascalName}Page(page);
|
|
2734
|
+
await use(${camelName}Page);
|
|
2735
|
+
},
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
export { expect } from '@playwright/test';
|
|
2739
|
+
`,
|
|
2740
|
+
knowledge: "playwright-fixtures"
|
|
2741
|
+
}
|
|
2742
|
+
];
|
|
2743
|
+
}
|
|
2744
|
+
var generators = {
|
|
2745
|
+
"vue-feature": generateVueFeature,
|
|
2746
|
+
"nestjs-feature": generateNestJSFeature,
|
|
2747
|
+
"playwright-feature": generatePlaywrightFeature
|
|
2748
|
+
};
|
|
2749
|
+
function generateNestJsBffFeature(outputPath, contractPath, endpointName, contractsLib) {
|
|
2750
|
+
const parseResult = parseContractFile(contractPath);
|
|
2751
|
+
if ("success" in parseResult && !parseResult.success) {
|
|
2752
|
+
return { success: false, error: parseResult.error };
|
|
2753
|
+
}
|
|
2754
|
+
const contract = parseResult;
|
|
2755
|
+
const endpoint = contract.endpoints.find((e) => e.name === endpointName);
|
|
2756
|
+
if (!endpoint) {
|
|
2757
|
+
return {
|
|
2758
|
+
success: false,
|
|
2759
|
+
error: `Endpoint '${endpointName}' not found in contract. Available: ${contract.endpoints.map((e) => e.name).join(", ")}`
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
const result = generateNestJsFeature(endpoint, {
|
|
2763
|
+
contractName: contract.contractName,
|
|
2764
|
+
contractsLib,
|
|
2765
|
+
featureName: endpointName
|
|
2766
|
+
});
|
|
2767
|
+
const files = result.files.map((f) => ({
|
|
2768
|
+
path: join3(outputPath, f.path),
|
|
2769
|
+
content: f.content,
|
|
2770
|
+
knowledge: f.isComplete ? void 0 : "nestjs-bff-skeleton"
|
|
2771
|
+
}));
|
|
2772
|
+
return {
|
|
2773
|
+
success: true,
|
|
2774
|
+
files,
|
|
2775
|
+
summary: summarizeGeneration(result)
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
async function scaffoldCore(options) {
|
|
2779
|
+
const featureType = options.type;
|
|
2780
|
+
if (featureType === "nestjs-bff-feature") {
|
|
2781
|
+
if (!options.contractPath || !options.endpointName) {
|
|
2782
|
+
return {
|
|
2783
|
+
success: false,
|
|
2784
|
+
command: "scaffold",
|
|
2785
|
+
error: "nestjs-bff-feature requires contractPath and endpointName options",
|
|
2786
|
+
availableTypes: [...Object.keys(generators), "nestjs-bff-feature"]
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
const contractsLib = options.contractsLib ?? "@adeo/ahs-operator-execution-contracts";
|
|
2790
|
+
const absolutePath2 = resolve3(options.path);
|
|
2791
|
+
const absoluteContractPath = resolve3(options.contractPath);
|
|
2792
|
+
const bffResult = generateNestJsBffFeature(
|
|
2793
|
+
absolutePath2,
|
|
2794
|
+
absoluteContractPath,
|
|
2795
|
+
options.endpointName,
|
|
2796
|
+
contractsLib
|
|
2797
|
+
);
|
|
2798
|
+
if (!bffResult.success || !bffResult.files) {
|
|
2799
|
+
return {
|
|
2800
|
+
success: false,
|
|
2801
|
+
command: "scaffold",
|
|
2802
|
+
error: bffResult.error
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
if (options.dryRun) {
|
|
2806
|
+
return {
|
|
2807
|
+
success: true,
|
|
2808
|
+
command: "scaffold",
|
|
2809
|
+
dryRun: true,
|
|
2810
|
+
type: featureType,
|
|
2811
|
+
path: absolutePath2,
|
|
2812
|
+
files: bffResult.files.map((f) => ({
|
|
2813
|
+
path: f.path,
|
|
2814
|
+
knowledge: f.knowledge
|
|
2815
|
+
}))
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
const created2 = [];
|
|
2819
|
+
const errors2 = [];
|
|
2820
|
+
for (const file of bffResult.files) {
|
|
2821
|
+
try {
|
|
2822
|
+
const dir = dirname3(file.path);
|
|
2823
|
+
if (!existsSync6(dir)) {
|
|
2824
|
+
mkdirSync(dir, { recursive: true });
|
|
2825
|
+
}
|
|
2826
|
+
writeFileSync(file.path, file.content, "utf-8");
|
|
2827
|
+
created2.push(file.path);
|
|
2828
|
+
} catch (err) {
|
|
2829
|
+
errors2.push({
|
|
2830
|
+
path: file.path,
|
|
2831
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
return {
|
|
2836
|
+
success: errors2.length === 0,
|
|
2837
|
+
command: "scaffold",
|
|
2838
|
+
type: featureType,
|
|
2839
|
+
path: absolutePath2,
|
|
2840
|
+
created: created2,
|
|
2841
|
+
errors: errors2.length > 0 ? errors2 : void 0
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
const generator = generators[featureType];
|
|
2845
|
+
if (!generator) {
|
|
2846
|
+
return {
|
|
2847
|
+
success: false,
|
|
2848
|
+
command: "scaffold",
|
|
2849
|
+
error: `Unknown feature type: ${options.type}`,
|
|
2850
|
+
availableTypes: [...Object.keys(generators), "nestjs-bff-feature"]
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
const absolutePath = resolve3(options.path);
|
|
2854
|
+
const files = generator(absolutePath);
|
|
2855
|
+
if (options.dryRun) {
|
|
2856
|
+
return {
|
|
2857
|
+
success: true,
|
|
2858
|
+
command: "scaffold",
|
|
2859
|
+
dryRun: true,
|
|
2860
|
+
type: featureType,
|
|
2861
|
+
path: absolutePath,
|
|
2862
|
+
files: files.map((f) => ({
|
|
2863
|
+
path: f.path,
|
|
2864
|
+
knowledge: f.knowledge,
|
|
2865
|
+
rules: f.rules
|
|
2866
|
+
}))
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
const created = [];
|
|
2870
|
+
const errors = [];
|
|
2871
|
+
for (const file of files) {
|
|
2872
|
+
try {
|
|
2873
|
+
const dir = dirname3(file.path);
|
|
2874
|
+
if (!existsSync6(dir)) {
|
|
2875
|
+
mkdirSync(dir, { recursive: true });
|
|
2876
|
+
}
|
|
2877
|
+
writeFileSync(file.path, file.content, "utf-8");
|
|
2878
|
+
created.push(file.path);
|
|
2879
|
+
} catch (err) {
|
|
2880
|
+
errors.push({
|
|
2881
|
+
path: file.path,
|
|
2882
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
return {
|
|
2887
|
+
success: errors.length === 0,
|
|
2888
|
+
command: "scaffold",
|
|
2889
|
+
type: featureType,
|
|
2890
|
+
path: absolutePath,
|
|
2891
|
+
created,
|
|
2892
|
+
errors: errors.length > 0 ? errors : void 0,
|
|
2893
|
+
knowledges: files.filter((f) => f.knowledge).map((f) => ({
|
|
2894
|
+
path: f.path,
|
|
2895
|
+
knowledge: f.knowledge,
|
|
2896
|
+
rules: f.rules
|
|
2897
|
+
}))
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
async function scaffoldCommand(type, path, options) {
|
|
2901
|
+
const result = await scaffoldCore({
|
|
2902
|
+
type,
|
|
2903
|
+
path,
|
|
2904
|
+
dryRun: options.dryRun,
|
|
2905
|
+
contractPath: options.contractPath,
|
|
2906
|
+
endpointName: options.endpointName,
|
|
2907
|
+
contractsLib: options.contractsLib
|
|
2908
|
+
});
|
|
2909
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2910
|
+
if (!result.success) {
|
|
2911
|
+
process.exit(1);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
export {
|
|
2916
|
+
lintCore,
|
|
2917
|
+
lintCommand,
|
|
2918
|
+
analyzeCore,
|
|
2919
|
+
analyzeCommand,
|
|
2920
|
+
scaffoldCore,
|
|
2921
|
+
scaffoldCommand
|
|
2922
|
+
};
|