eva4j 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/AGENTS.md +314 -10
  2. package/COMMAND_EVALUATION.md +15 -16
  3. package/DOMAIN_YAML_GUIDE.md +576 -10
  4. package/FUTURE_FEATURES.md +1627 -1168
  5. package/README.md +318 -13
  6. package/bin/eva4j.js +34 -0
  7. package/config/defaults.json +1 -0
  8. package/design-system.md +797 -0
  9. package/docs/commands/EVALUATE_SYSTEM.md +994 -0
  10. package/docs/commands/GENERATE_ENTITIES.md +795 -6
  11. package/docs/commands/INDEX.md +10 -1
  12. package/examples/domain-endpoints-relations.yaml +353 -0
  13. package/examples/domain-endpoints-versioned.yaml +144 -0
  14. package/examples/domain-endpoints.yaml +135 -0
  15. package/examples/domain-events.yaml +166 -20
  16. package/examples/domain-listeners.yaml +212 -0
  17. package/examples/domain-one-to-many.yaml +1 -0
  18. package/examples/domain-one-to-one.yaml +1 -0
  19. package/examples/domain-ports.yaml +414 -0
  20. package/examples/domain-soft-delete.yaml +47 -44
  21. package/examples/system/notification.yaml +147 -0
  22. package/examples/system/product.yaml +185 -0
  23. package/examples/system/system.yaml +112 -0
  24. package/examples/system-report.html +971 -0
  25. package/examples/system.yaml +332 -0
  26. package/package.json +2 -1
  27. package/src/commands/build.js +714 -0
  28. package/src/commands/create.js +7 -3
  29. package/src/commands/detach.js +1 -0
  30. package/src/commands/evaluate-system.js +610 -0
  31. package/src/commands/generate-entities.js +1331 -49
  32. package/src/commands/generate-http-exchange.js +2 -0
  33. package/src/commands/generate-kafka-event.js +98 -11
  34. package/src/generators/base-generator.js +8 -1
  35. package/src/generators/postman-generator.js +188 -0
  36. package/src/generators/shared-generator.js +10 -0
  37. package/src/utils/config-manager.js +54 -0
  38. package/src/utils/context-builder.js +1 -0
  39. package/src/utils/domain-diagram.js +192 -0
  40. package/src/utils/domain-validator.js +970 -0
  41. package/src/utils/fake-data.js +376 -0
  42. package/src/utils/naming.js +3 -2
  43. package/src/utils/system-validator.js +434 -0
  44. package/src/utils/yaml-to-entity.js +302 -8
  45. package/templates/aggregate/AggregateMapper.java.ejs +3 -2
  46. package/templates/aggregate/AggregateRepository.java.ejs +8 -2
  47. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +13 -3
  48. package/templates/aggregate/AggregateRoot.java.ejs +60 -2
  49. package/templates/aggregate/DomainEventHandler.java.ejs +27 -20
  50. package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
  51. package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
  52. package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
  53. package/templates/aggregate/JpaRepository.java.ejs +5 -0
  54. package/templates/base/gradle/build.gradle.ejs +3 -2
  55. package/templates/base/root/AGENTS.md.ejs +306 -45
  56. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1663 -0
  57. package/templates/base/root/skill-build-system-yaml.ejs +1446 -0
  58. package/templates/base/root/system.yaml.ejs +97 -0
  59. package/templates/crud/ApplicationMapper.java.ejs +4 -0
  60. package/templates/crud/Controller.java.ejs +4 -4
  61. package/templates/crud/CreateCommand.java.ejs +4 -0
  62. package/templates/crud/CreateItemDto.java.ejs +4 -0
  63. package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
  64. package/templates/crud/DeleteCommandHandler.java.ejs +10 -2
  65. package/templates/crud/EndpointsController.java.ejs +178 -0
  66. package/templates/crud/FindByQuery.java.ejs +17 -0
  67. package/templates/crud/FindByQueryHandler.java.ejs +57 -0
  68. package/templates/crud/ListQuery.java.ejs +1 -1
  69. package/templates/crud/ListQueryHandler.java.ejs +8 -8
  70. package/templates/crud/ScaffoldCommand.java.ejs +12 -0
  71. package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
  72. package/templates/crud/ScaffoldQuery.java.ejs +13 -0
  73. package/templates/crud/ScaffoldQueryHandler.java.ejs +41 -0
  74. package/templates/crud/SubEntityAddCommand.java.ejs +21 -0
  75. package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
  76. package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
  77. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
  78. package/templates/crud/TransitionCommand.java.ejs +9 -0
  79. package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
  80. package/templates/crud/UpdateCommand.java.ejs +4 -0
  81. package/templates/evaluate/report.html.ejs +1363 -0
  82. package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
  83. package/templates/kafka-event/Event.java.ejs +16 -0
  84. package/templates/kafka-listener/KafkaController.java.ejs +1 -1
  85. package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
  86. package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
  87. package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
  88. package/templates/kafka-listener/ListenerCommandHandler.java.ejs +23 -0
  89. package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
  90. package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
  91. package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
  92. package/templates/mock/MockEvent.java.ejs +10 -0
  93. package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
  94. package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
  95. package/templates/mock/SpringEventListener.java.ejs +61 -0
  96. package/templates/ports/PortDomainModel.java.ejs +35 -0
  97. package/templates/ports/PortFeignAdapter.java.ejs +67 -0
  98. package/templates/ports/PortFeignClient.java.ejs +45 -0
  99. package/templates/ports/PortFeignConfig.java.ejs +24 -0
  100. package/templates/ports/PortInterface.java.ejs +45 -0
  101. package/templates/ports/PortNestedType.java.ejs +28 -0
  102. package/templates/ports/PortRequestDto.java.ejs +30 -0
  103. package/templates/ports/PortResponseDto.java.ejs +28 -0
  104. package/templates/postman/Collection.json.ejs +1 -1
  105. package/templates/postman/UnifiedCollection.json.ejs +185 -0
  106. package/templates/shared/configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs +109 -0
@@ -0,0 +1,970 @@
1
+ 'use strict';
2
+
3
+ const { pluralizeWord } = require('./naming');
4
+
5
+ /**
6
+ * Domain-level validator for eva evaluate system --domain
7
+ *
8
+ * Receives:
9
+ * domainConfigs — Map<moduleName, parsedDomainYaml>
10
+ * systemConfig — parsed system.yaml
11
+ *
12
+ * Returns: { summary, categories[], diagrams }
13
+ *
14
+ * Categories:
15
+ * C1 — Kafka Event Contracts
16
+ * C4 — Behavior Gaps
17
+ * C5 — Cross-Reference Integrity
18
+ * C6 — Audit & Traceability
19
+ */
20
+
21
+ // ── Internal helpers ─────────────────────────────────────────────────────────
22
+
23
+ /** Returns true when the target name looks like an external service (not a local module). */
24
+ function isExternalService(targetName, baseUrl) {
25
+ if (!targetName) return false;
26
+ if (/-external$/i.test(targetName)) return true;
27
+ if (baseUrl && /^https?:\/\//i.test(baseUrl) && !/localhost/i.test(baseUrl)) return true;
28
+ return false;
29
+ }
30
+
31
+ /**
32
+ * Split a PascalCase or camelCase identifier into lowercase words (length > 2).
33
+ * "confirmPayment" → ["confirm", "payment"]
34
+ * "ReserveOrderStock" → ["reserve", "order", "stock"]
35
+ */
36
+ function extractWords(name) {
37
+ if (!name) return [];
38
+ return name
39
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
40
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
41
+ .toLowerCase()
42
+ .split(/[\s_-]+/)
43
+ .filter((w) => w.length > 2);
44
+ }
45
+
46
+ /**
47
+ * Stem-aware word overlap: returns true if any word in wordsA is a prefix (or equal) to
48
+ * any word in wordsB, and vice-versa — so "ship" matches "shipment".
49
+ */
50
+ function wordsOverlap(wordsA, wordsB) {
51
+ for (const a of wordsA) {
52
+ for (const b of wordsB) {
53
+ if (a.startsWith(b) || b.startsWith(a)) return true;
54
+ // Shared-prefix matching: "reservation" ↔ "reserve" share "reser" (5+ chars)
55
+ const minLen = Math.min(a.length, b.length);
56
+ if (minLen >= 5 && a.substring(0, 5) === b.substring(0, 5)) return true;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /**
63
+ * Fuzzy field-name matching for C4-004: checks exact match, then suffix match.
64
+ * e.g. "cancellationReason" matches event field "reason" (suffix),
65
+ * "failureReason" matches "reason" (suffix).
66
+ */
67
+ function fieldMatchesAnyEventField(fieldName, eventFieldNames) {
68
+ if (eventFieldNames.has(fieldName)) return true;
69
+ const lower = fieldName.toLowerCase();
70
+ for (const ef of eventFieldNames) {
71
+ const efLower = ef.toLowerCase();
72
+ if (lower.endsWith(efLower) && efLower.length >= 3) return true;
73
+ if (efLower.endsWith(lower) && lower.length >= 3) return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /** Critical module heuristic */
79
+ function isCriticalModule(name) {
80
+ return /payment|billing|order|reservation|customer|user|inventory/i.test(name);
81
+ }
82
+
83
+ /** Normalize a URL path for comparison: lowercase, trim trailing slash, replace {param} with {x} */
84
+ function pathNormalize(p) {
85
+ if (!p) return '';
86
+ return p
87
+ .toLowerCase()
88
+ .replace(/\/$/, '')
89
+ .replace(/\{[^}]+\}/g, '{x}')
90
+ .trim();
91
+ }
92
+
93
+ /**
94
+ * Build map: eventName → { moduleName, fields: [{name, type}] }
95
+ * from domain aggregates[].events[]
96
+ */
97
+ function buildProducedEvents(domainConfigs) {
98
+ const map = {};
99
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
100
+ for (const agg of config.aggregates || []) {
101
+ for (const ev of agg.events || []) {
102
+ map[ev.name] = {
103
+ moduleName,
104
+ fields: ev.fields || [],
105
+ };
106
+ }
107
+ }
108
+ }
109
+ return map;
110
+ }
111
+
112
+ /**
113
+ * Build map: eventName → { producer, topic, consumers: string[] }
114
+ * from system.yaml integrations.async[]
115
+ */
116
+ function buildSystemAsyncMap(systemConfig) {
117
+ const map = {};
118
+ for (const ev of (systemConfig.integrations || {}).async || []) {
119
+ map[ev.event] = {
120
+ producer: ev.producer,
121
+ topic: ev.topic,
122
+ consumers: (ev.consumers || []).map((c) => (typeof c === 'string' ? c : c.module)),
123
+ };
124
+ }
125
+ return map;
126
+ }
127
+
128
+ /**
129
+ * Returns all declared endpoint strings "METHOD /path" for a module,
130
+ * combining domain.yaml endpoints + system.yaml exposes[].
131
+ */
132
+ function getAllEndpoints(moduleName, domainConfig, systemConfig) {
133
+ const result = new Set();
134
+
135
+ // domain.yaml endpoints section
136
+ if (domainConfig) {
137
+ const ep = domainConfig.endpoints;
138
+ if (ep) {
139
+ for (const ver of ep.versions || []) {
140
+ for (const op of ver.operations || []) {
141
+ result.add(`${(op.method || '').toUpperCase()} ${op.path || ''}`);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ // system.yaml exposes
148
+ const sysMod = (systemConfig.modules || []).find((m) => m.name === moduleName);
149
+ if (sysMod) {
150
+ for (const ep of sysMod.exposes || []) {
151
+ result.add(`${(ep.method || '').toUpperCase()} ${ep.path || ''}`);
152
+ }
153
+ }
154
+
155
+ return [...result];
156
+ }
157
+
158
+ /**
159
+ * Normalize a type string for comparison: strip whitespace, lowercase,
160
+ * treat List<X> as "list<x>".
161
+ */
162
+ function normalizeType(t) {
163
+ if (!t) return '';
164
+ return t.toLowerCase().replace(/\s+/g, '');
165
+ }
166
+
167
+ /**
168
+ * Returns true when two type strings are considered compatible.
169
+ * Handles common aliases (Integer/int, Long/long, etc.) and List variants.
170
+ */
171
+ function typesCompatible(a, b) {
172
+ const na = normalizeType(a);
173
+ const nb = normalizeType(b);
174
+ if (na === nb) return true;
175
+
176
+ const aliases = {
177
+ integer: ['int', 'integer'],
178
+ long: ['long'],
179
+ double: ['double', 'float'],
180
+ boolean: ['boolean', 'bool'],
181
+ string: ['string'],
182
+ bigdecimal: ['bigdecimal', 'decimal'],
183
+ localdate: ['localdate'],
184
+ localdatetime: ['localdatetime'],
185
+ localtime: ['localtime'],
186
+ instant: ['instant'],
187
+ uuid: ['uuid'],
188
+ };
189
+
190
+ for (const group of Object.values(aliases)) {
191
+ if (group.includes(na) && group.includes(nb)) return true;
192
+ }
193
+ return false;
194
+ }
195
+
196
+ // ── Finding builders ─────────────────────────────────────────────────────────
197
+
198
+ function finding(module, message, context) {
199
+ return { module, message, context: context || '' };
200
+ }
201
+
202
+ // ── Check runners ────────────────────────────────────────────────────────────
203
+
204
+ // ─── C1 — Kafka Event Contracts ─────────────────────────────────────────────
205
+
206
+ function runC1(domainConfigs, systemConfig) {
207
+ const producedEvents = buildProducedEvents(domainConfigs);
208
+ const systemAsyncMap = buildSystemAsyncMap(systemConfig);
209
+ const allModuleNames = new Set(Object.keys(domainConfigs));
210
+
211
+ const checks = {
212
+ 'C1-001': { label: 'Evento producido sin consumidor en system.yaml', severity: 'ok', findings: [] },
213
+ 'C1-002': { label: 'Listener referencia evento que ningún módulo produce', severity: 'ok', findings: [] },
214
+ 'C1-003': { label: 'Campo en listener.fields no existe en el evento del productor', severity: 'ok', findings: [] },
215
+ 'C1-004': { label: 'Campo existe pero con tipo incompatible productor/consumidor', severity: 'ok', findings: [] },
216
+ 'C1-005': { label: 'system.yaml registra consumidor pero módulo no tiene listener declarado', severity: 'ok', findings: [] },
217
+ 'C1-006': { label: 'Listener declara producer: incorrecto', severity: 'ok', findings: [] },
218
+ };
219
+
220
+ // C1-001: produced event in domain but zero consumers in system.yaml
221
+ for (const [eventName, info] of Object.entries(producedEvents)) {
222
+ const sysEntry = systemAsyncMap[eventName];
223
+ if (!sysEntry || sysEntry.consumers.length === 0) {
224
+ checks['C1-001'].findings.push(
225
+ finding(info.moduleName, `El evento '${eventName}' no tiene consumidores registrados en system.yaml`, `Producido por: ${info.moduleName}`)
226
+ );
227
+ }
228
+ }
229
+
230
+ // C1-002: listener references event that no domain produces
231
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
232
+ for (const listener of config.listeners || []) {
233
+ if (!producedEvents[listener.event]) {
234
+ checks['C1-002'].findings.push(
235
+ finding(moduleName, `Listener de '${listener.event}' pero ningún módulo en los domain.yaml lo produce`, `Declarado producer: ${listener.producer}`)
236
+ );
237
+ }
238
+ }
239
+ }
240
+
241
+ // C1-003 & C1-004: field-level contract comparison
242
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
243
+ for (const listener of config.listeners || []) {
244
+ const producerInfo = producedEvents[listener.event];
245
+ if (!producerInfo) continue; // already caught by C1-002
246
+
247
+ const producerFieldMap = {};
248
+ for (const f of producerInfo.fields) {
249
+ producerFieldMap[f.name] = f.type;
250
+ }
251
+
252
+ for (const lf of listener.fields || []) {
253
+ // Skip List<NestedType> fields — nestedTypes differ structurally
254
+ if (/^list</i.test((lf.type || '').replace(/\s/g, ''))) continue;
255
+
256
+ if (!(lf.name in producerFieldMap)) {
257
+ checks['C1-003'].findings.push(
258
+ finding(
259
+ moduleName,
260
+ `Campo '${lf.name}' en listener de '${listener.event}' no existe en los campos del evento del productor (${producerInfo.moduleName})`,
261
+ `Tipo esperado: ${lf.type}`
262
+ )
263
+ );
264
+ } else {
265
+ const producerType = producerFieldMap[lf.name];
266
+ if (!typesCompatible(lf.type, producerType)) {
267
+ checks['C1-004'].findings.push(
268
+ finding(
269
+ moduleName,
270
+ `Campo '${lf.name}' en listener de '${listener.event}': tipo incompatible`,
271
+ `Productor declara '${producerType}', listener declara '${lf.type}'`
272
+ )
273
+ );
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ // C1-005: system.yaml consumer present but module has no listener
281
+ for (const [eventName, sysEntry] of Object.entries(systemAsyncMap)) {
282
+ for (const consumerModule of sysEntry.consumers) {
283
+ const consumerConfig = domainConfigs[consumerModule];
284
+ if (!consumerConfig) continue; // module domain.yaml not loaded — skip
285
+ const hasListener = (consumerConfig.listeners || []).some((l) => l.event === eventName);
286
+ if (!hasListener) {
287
+ checks['C1-005'].findings.push(
288
+ finding(
289
+ consumerModule,
290
+ `system.yaml registra '${consumerModule}' como consumidor de '${eventName}' pero el módulo no tiene listener declarado`,
291
+ `Evento producido por: ${sysEntry.producer}`
292
+ )
293
+ );
294
+ }
295
+ }
296
+ }
297
+
298
+ // C1-006: listener.producer doesn't match the actual producer
299
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
300
+ for (const listener of config.listeners || []) {
301
+ const producerInfo = producedEvents[listener.event];
302
+ if (!producerInfo) continue; // caught by C1-002
303
+ if (listener.producer && listener.producer !== producerInfo.moduleName) {
304
+ checks['C1-006'].findings.push(
305
+ finding(
306
+ moduleName,
307
+ `Listener declara producer: '${listener.producer}' pero '${listener.event}' es producido por '${producerInfo.moduleName}'`,
308
+ `Evento: ${listener.event}`
309
+ )
310
+ );
311
+ }
312
+ }
313
+ }
314
+
315
+ // Assign severities
316
+ setDefaultSeverities(checks, {
317
+ 'C1-001': 'info',
318
+ 'C1-002': 'error',
319
+ 'C1-003': 'error',
320
+ 'C1-004': 'error',
321
+ 'C1-005': 'error',
322
+ 'C1-006': 'error',
323
+ });
324
+
325
+ return checks;
326
+ }
327
+
328
+ // ─── C2 — Behavior Gaps ─────────────────────────────────────────────────────
329
+
330
+ function runC2(domainConfigs, systemConfig) {
331
+ const checks = {
332
+ 'C2-001': { label: 'Transición de estado sin endpoint HTTP ni listener asociado', severity: 'ok', findings: [] },
333
+ 'C2-002': { label: 'UseCase de listener sin endpoint REST en módulo que expone REST', severity: 'ok', findings: [] },
334
+ 'C2-003': { label: 'Valor en enum *Type sin evento Kafka trazable que lo origine', severity: 'ok', findings: [] },
335
+ 'C2-004': { label: 'Trigger de evento referencia método de transición inexistente', severity: 'ok', findings: [] },
336
+ 'C2-005': { label: 'Transición de estado sin Domain Event asociado (sin trigger)', severity: 'ok', findings: [] },
337
+ 'C2-006': { label: 'Colisión de nombre de useCase entre endpoints y listeners', severity: 'ok', findings: [] },
338
+ 'C2-007': { label: 'UseCase FindAll con nombre de agregado sin pluralizar correctamente', severity: 'ok', findings: [] },
339
+ };
340
+
341
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
342
+ const allEndpoints = getAllEndpoints(moduleName, config, systemConfig);
343
+ const allListenerUseCases = (config.listeners || []).map((l) => l.useCase || '');
344
+
345
+ // Collect all useCases exposed
346
+ const endpointUseCases = new Set();
347
+ for (const ep of allEndpoints) {
348
+ // ep = "METHOD /path" — we don't have useCase here from system.yaml
349
+ }
350
+
351
+ // Collect useCases from domain.yaml endpoints section
352
+ const domainEndpointUseCases = new Set();
353
+ if (config.endpoints) {
354
+ for (const ver of config.endpoints.versions || []) {
355
+ for (const op of ver.operations || []) {
356
+ if (op.useCase) domainEndpointUseCases.add(op.useCase);
357
+ }
358
+ }
359
+ }
360
+ // From system.yaml exposes
361
+ const sysMod = (systemConfig.modules || []).find((m) => m.name === moduleName);
362
+ const sysUseCases = new Set();
363
+ if (sysMod) {
364
+ for (const ep of sysMod.exposes || []) {
365
+ if (ep.useCase) sysUseCases.add(ep.useCase);
366
+ }
367
+ }
368
+ const allKnownUseCases = new Set([...domainEndpointUseCases, ...sysUseCases]);
369
+ const allListenerUCSet = new Set(allListenerUseCases.filter(Boolean));
370
+
371
+ // Collect transition methods covered by event triggers — used to relax C2-001 and populate C2-005
372
+ const triggeredMethods = new Set();
373
+ for (const agg of config.aggregates || []) {
374
+ for (const ev of agg.events || []) {
375
+ for (const trigger of ev.triggers || []) {
376
+ triggeredMethods.add(trigger);
377
+ }
378
+ }
379
+ }
380
+
381
+ // C2-001: transition method has no matching endpoint nor listener
382
+ // Silenced when the method already has an event trigger (design evidence).
383
+ for (const agg of config.aggregates || []) {
384
+ for (const en of agg.enums || []) {
385
+ for (const tr of en.transitions || []) {
386
+ const methodName = tr.method;
387
+ if (!methodName) continue;
388
+
389
+ const methodWords = extractWords(methodName);
390
+ // Match against all known useCases (endpoints + listeners)
391
+ const allUCWords = [...allKnownUseCases, ...allListenerUCSet];
392
+ const matched = allUCWords.some((uc) => wordsOverlap(methodWords, extractWords(uc)));
393
+
394
+ if (!matched && !triggeredMethods.has(methodName)) {
395
+ checks['C2-001'].findings.push(
396
+ finding(
397
+ moduleName,
398
+ `Transición '${methodName}' de ${en.name} (${tr.from} → ${tr.to}) no tiene endpoint HTTP ni listener asociado`,
399
+ `Agregado: ${agg.name}, Enum: ${en.name}. Nota: puede invocarse internamente desde otro use case del módulo`
400
+ )
401
+ );
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ // C2-004: event trigger references a method that does not exist in any transition
408
+ const allTransitionMethods = new Set();
409
+ for (const agg of config.aggregates || []) {
410
+ for (const en of agg.enums || []) {
411
+ for (const tr of en.transitions || []) {
412
+ if (tr.method) allTransitionMethods.add(tr.method);
413
+ }
414
+ }
415
+ }
416
+ for (const agg of config.aggregates || []) {
417
+ for (const ev of agg.events || []) {
418
+ for (const trigger of ev.triggers || []) {
419
+ if (!allTransitionMethods.has(trigger)) {
420
+ checks['C2-004'].findings.push(
421
+ finding(
422
+ moduleName,
423
+ `Evento '${ev.name}' tiene trigger '${trigger}' que no corresponde a ningún método de transición`,
424
+ `Métodos disponibles: ${[...allTransitionMethods].join(', ') || '(ninguno)'}`
425
+ )
426
+ );
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ // C2-005: transition method without any associated domain event trigger
433
+ for (const agg of config.aggregates || []) {
434
+ for (const en of agg.enums || []) {
435
+ for (const tr of en.transitions || []) {
436
+ if (tr.method && !triggeredMethods.has(tr.method)) {
437
+ checks['C2-005'].findings.push(
438
+ finding(
439
+ moduleName,
440
+ `Transición '${tr.method}' (${en.name}: ${tr.from} → ${tr.to}) no tiene ningún Domain Event asociado`,
441
+ `Considerar declarar un evento con triggers: [${tr.method}]`
442
+ )
443
+ );
444
+ }
445
+ }
446
+ }
447
+ }
448
+
449
+ // C2-002: listener useCase has no corresponding REST endpoint (info level)
450
+ const hasRestEndpoints = allEndpoints.length > 0;
451
+ if (hasRestEndpoints) {
452
+ for (const listener of config.listeners || []) {
453
+ const uc = listener.useCase;
454
+ if (!uc) continue;
455
+ if (!allKnownUseCases.has(uc)) {
456
+ checks['C2-002'].findings.push(
457
+ finding(
458
+ moduleName,
459
+ `UseCase '${uc}' (listener de '${listener.event}') no tiene endpoint REST equivalente`,
460
+ `El módulo expone REST pero este useCase solo se activa vía evento`
461
+ )
462
+ );
463
+ }
464
+ }
465
+ }
466
+
467
+ // C2-003: value in a *Type enum with no traceable event in the module
468
+ // Collect all event field types and event names in this module
469
+ const moduleEventTokens = new Set();
470
+ for (const agg of config.aggregates || []) {
471
+ for (const ev of agg.events || []) {
472
+ for (const w of extractWords(ev.name)) moduleEventTokens.add(w);
473
+ for (const f of ev.fields || []) {
474
+ if (f.type && /^[A-Z]/.test(f.type)) {
475
+ for (const w of extractWords(f.type)) moduleEventTokens.add(w);
476
+ }
477
+ }
478
+ }
479
+ }
480
+ // Also include listener event names, field names, and useCase names as traceable origins.
481
+ // Field names cover cases like wasLateReturn → [late, return] tracing LATE_RETURN,
482
+ // where the semantic connection is in a boolean field name, not the event name itself.
483
+ // UseCase names cover cases like ReserveStock → [reserve, stock] tracing RESERVATION,
484
+ // where the enum value originates from a listener-triggered use case.
485
+ for (const listener of config.listeners || []) {
486
+ for (const w of extractWords(listener.event)) moduleEventTokens.add(w);
487
+ for (const w of extractWords(listener.useCase || '')) moduleEventTokens.add(w);
488
+ for (const f of listener.fields || []) {
489
+ for (const w of extractWords(f.name)) moduleEventTokens.add(w);
490
+ }
491
+ }
492
+ // Also include endpoint useCase names as traceable origins.
493
+ // Values like DAMAGE_REPORT originate from an HTTP action (e.g. RegisterIncident),
494
+ // not from a Kafka event — the useCase name provides the semantic trace.
495
+ const epSection = config.endpoints;
496
+ for (const ver of (epSection && epSection.versions) || []) {
497
+ for (const op of ver.operations || []) {
498
+ for (const w of extractWords(op.useCase || '')) moduleEventTokens.add(w);
499
+ }
500
+ }
501
+
502
+ for (const agg of config.aggregates || []) {
503
+ for (const en of agg.enums || []) {
504
+ if (!en.name.endsWith('Type')) continue;
505
+ for (const val of en.values || []) {
506
+ const valWords = extractWords(val);
507
+ const traceable = valWords.some((w) => moduleEventTokens.has(w))
508
+ || valWords.some((w) => [...moduleEventTokens].some((t) => wordsOverlap([w], [t])));
509
+ if (!traceable) {
510
+ checks['C2-003'].findings.push(
511
+ finding(
512
+ moduleName,
513
+ `Valor '${val}' en ${en.name} no tiene mecanismo trazable que lo origine (ni evento Kafka ni endpoint HTTP)`,
514
+ `Enum: ${en.name} en agregado ${agg.name}`
515
+ )
516
+ );
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ // C2-006: useCase name collision between endpoints and listeners
523
+ // Both generate "{UseCase}Command.java" — the endpoint run overwrites the listener version.
524
+ const domainEpUseCases = new Set();
525
+ for (const ver of (config.endpoints && config.endpoints.versions) || []) {
526
+ for (const op of ver.operations || []) {
527
+ if (op.useCase) domainEpUseCases.add(op.useCase);
528
+ }
529
+ }
530
+ for (const listener of config.listeners || []) {
531
+ const uc = listener.useCase;
532
+ if (uc && domainEpUseCases.has(uc)) {
533
+ checks['C2-006'].findings.push(
534
+ finding(
535
+ moduleName,
536
+ `UseCase '${uc}' está declarado en endpoints: y en listeners: (evento '${listener.event}')`,
537
+ `Ambos generan '${uc}Command.java' — el endpoint sobreescribe el comando del listener. Renombra el useCase del listener, p.ej. '${uc.replace(/^Create/, 'Initialize')}'.`
538
+ )
539
+ );
540
+ }
541
+ }
542
+
543
+ // C2-007: FindAll use case name must use proper English plural of the aggregate
544
+ for (const agg of config.aggregates || []) {
545
+ const aggName = agg.name;
546
+ const expectedPlural = pluralizeWord(aggName);
547
+ const expectedFindAll = `FindAll${expectedPlural}`;
548
+
549
+ for (const ver of (config.endpoints && config.endpoints.versions) || []) {
550
+ for (const op of ver.operations || []) {
551
+ const uc = op.useCase || '';
552
+ if (!uc.startsWith('FindAll')) continue;
553
+ const suffix = uc.slice(7); // after 'FindAll'
554
+ // Match singular name or naive 's' suffix targeting this aggregate
555
+ if (suffix === aggName || suffix === `${aggName}s`) {
556
+ if (uc !== expectedFindAll) {
557
+ checks['C2-007'].findings.push(
558
+ finding(
559
+ moduleName,
560
+ `UseCase '${uc}' debería ser '${expectedFindAll}' (plural correcto de '${aggName}')`,
561
+ `Agregado: ${aggName}, versión: ${ver.version}. Sin el plural correcto, el generador creará un scaffold en lugar de la implementación estándar paginada.`
562
+ )
563
+ );
564
+ }
565
+ }
566
+ }
567
+ }
568
+ }
569
+ }
570
+
571
+ setDefaultSeverities(checks, {
572
+ 'C2-001': 'warning',
573
+ 'C2-002': 'info',
574
+ 'C2-003': 'warning',
575
+ 'C2-004': 'error',
576
+ 'C2-005': 'info',
577
+ 'C2-006': 'error',
578
+ 'C2-007': 'error',
579
+ });
580
+
581
+ return checks;
582
+ }
583
+
584
+ // ─── C3 — Cross-Reference Integrity ─────────────────────────────────────────
585
+
586
+ function runC3(domainConfigs, systemConfig) {
587
+ const checks = {
588
+ 'C3-001': { label: 'Campo con reference.module=X sin port ni listener hacia X', severity: 'ok', findings: [] },
589
+ 'C3-002': { label: 'Port apunta a módulo interno inexistente en domain.yaml', severity: 'ok', findings: [] },
590
+ 'C3-003': { label: 'Port llama endpoint no declarado en módulo destino', severity: 'ok', findings: [] },
591
+ 'C3-004': { label: 'Dependencia síncrona a módulo que no emite eventos Kafka', severity: 'ok', findings: [] },
592
+ 'C3-005': { label: 'Acoplamiento síncrono bidireccional entre dos módulos', severity: 'ok', findings: [] },
593
+ 'C3-006': { label: 'system.yaml declara llamada síncrona pero módulo no tiene port correspondiente', severity: 'ok', findings: [] },
594
+ };
595
+
596
+ const internalModuleNames = new Set(Object.keys(domainConfigs));
597
+ const sysModuleNames = new Set((systemConfig.modules || []).map((m) => m.name));
598
+
599
+ // Build sync caller→callees map from domain ports
600
+ const domainSyncCallers = {}; // moduleName → Set<targetModule>
601
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
602
+ domainSyncCallers[moduleName] = new Set();
603
+ for (const port of config.ports || []) {
604
+ if (!isExternalService(port.target, port.baseUrl)) {
605
+ domainSyncCallers[moduleName].add(port.target);
606
+ }
607
+ }
608
+ }
609
+
610
+ // Build sync caller→callees from system.yaml integrations.sync
611
+ const sysSyncCallers = {}; // moduleName → Set<targetModule>
612
+ for (const sync of (systemConfig.integrations || {}).sync || []) {
613
+ if (!sysSyncCallers[sync.caller]) sysSyncCallers[sync.caller] = new Set();
614
+ sysSyncCallers[sync.caller].add(sync.calls);
615
+ }
616
+
617
+ // Modules that produce events
618
+ const eventProducers = new Set();
619
+ for (const ev of (systemConfig.integrations || {}).async || []) {
620
+ eventProducers.add(ev.producer);
621
+ }
622
+
623
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
624
+ // C3-001: field with reference.module=X but no port[].target=X and no listener[].producer=X
625
+ for (const agg of config.aggregates || []) {
626
+ for (const entity of agg.entities || []) {
627
+ for (const field of entity.fields || []) {
628
+ const ref = field.reference;
629
+ if (!ref || !ref.module) continue;
630
+ if (ref.module === moduleName) continue; // same module — fine
631
+
632
+ const hasPort = (config.ports || []).some((p) => p.target === ref.module);
633
+ const hasListener = (config.listeners || []).some((l) => l.producer === ref.module);
634
+ if (!hasPort && !hasListener) {
635
+ checks['C3-001'].findings.push(
636
+ finding(
637
+ moduleName,
638
+ `Campo '${field.name}' referencia módulo '${ref.module}' pero no hay port ni listener que conecte con ese módulo`,
639
+ `Entidad: ${entity.name}, Ref aggregate: ${ref.aggregate || '?'}`
640
+ )
641
+ );
642
+ }
643
+ }
644
+ }
645
+ }
646
+
647
+ // C3-002: port.target is a known internal module but no domain.yaml was found for it
648
+ for (const port of config.ports || []) {
649
+ const target = port.target;
650
+ if (isExternalService(target, port.baseUrl)) continue;
651
+ if (sysModuleNames.has(target) && !internalModuleNames.has(target)) {
652
+ checks['C3-002'].findings.push(
653
+ finding(
654
+ moduleName,
655
+ `Port '${port.service}' apunta a '${target}' que está en system.yaml pero no tiene domain.yaml cargado`,
656
+ `Port: ${port.name || port.service}`
657
+ )
658
+ );
659
+ }
660
+ }
661
+
662
+ // C3-003: port method calls an endpoint not declared in the target module
663
+ for (const port of config.ports || []) {
664
+ const target = port.target;
665
+ if (isExternalService(target, port.baseUrl)) continue;
666
+ const targetDomain = domainConfigs[target];
667
+ if (!targetDomain && !sysModuleNames.has(target)) continue;
668
+
669
+ const targetEndpoints = getAllEndpoints(target, targetDomain || null, systemConfig);
670
+ const normalizedTargetEps = targetEndpoints.map((ep) => {
671
+ const [method, ...pathParts] = ep.split(' ');
672
+ return `${method} ${pathNormalize(pathParts.join(' '))}`;
673
+ });
674
+
675
+ // http field format: "METHOD /path"
676
+ if (port.http) {
677
+ const [method, ...pathParts] = port.http.split(' ');
678
+ const normalizedCall = `${method.toUpperCase()} ${pathNormalize(pathParts.join(' '))}`;
679
+ const found = normalizedTargetEps.some((ep) => ep === normalizedCall);
680
+ if (!found && targetEndpoints.length > 0) {
681
+ checks['C3-003'].findings.push(
682
+ finding(
683
+ moduleName,
684
+ `Port '${port.name || port.service}' llama '${port.http}' en '${target}' pero ese endpoint no está declarado en el módulo destino`,
685
+ `Target: ${target}`
686
+ )
687
+ );
688
+ }
689
+ }
690
+ }
691
+
692
+ // C3-004: module calls a sync dependency that doesn't emit any Kafka events
693
+ for (const port of config.ports || []) {
694
+ const target = port.target;
695
+ if (isExternalService(target, port.baseUrl)) continue;
696
+ if (!sysModuleNames.has(target)) continue;
697
+ if (!eventProducers.has(target)) {
698
+ checks['C3-004'].findings.push(
699
+ finding(
700
+ moduleName,
701
+ `'${moduleName}' tiene dependencia síncrona con '${target}' pero '${target}' no emite eventos Kafka`,
702
+ `Port: ${port.service}`
703
+ )
704
+ );
705
+ }
706
+ }
707
+ }
708
+
709
+ // C3-005: bidirectional sync coupling (A→B and B→A in system.yaml)
710
+ const seenPairs = new Set();
711
+ for (const [callerA, calleesA] of Object.entries(sysSyncCallers)) {
712
+ for (const calleeB of calleesA) {
713
+ if (seenPairs.has(`${calleeB}→${callerA}`)) continue; // keep one direction
714
+ if (sysSyncCallers[calleeB] && sysSyncCallers[calleeB].has(callerA)) {
715
+ checks['C3-005'].findings.push(
716
+ finding(
717
+ callerA,
718
+ `Acoplamiento síncrono bidireccional entre '${callerA}' y '${calleeB}'`,
719
+ `${callerA} llama a ${calleeB} y ${calleeB} llama a ${callerA}`
720
+ )
721
+ );
722
+ seenPairs.add(`${callerA}→${calleeB}`);
723
+ }
724
+ }
725
+ }
726
+
727
+ // C3-006: system.yaml sync call but caller module has no matching port
728
+ for (const sync of (systemConfig.integrations || {}).sync || []) {
729
+ const callerConfig = domainConfigs[sync.caller];
730
+ if (!callerConfig) continue;
731
+ const target = sync.calls;
732
+ const hasPort = (callerConfig.ports || []).some((p) => p.target === target);
733
+ if (!hasPort) {
734
+ checks['C3-006'].findings.push(
735
+ finding(
736
+ sync.caller,
737
+ `system.yaml declara que '${sync.caller}' llama síncronamente a '${target}' pero el módulo no tiene port declarado hacia '${target}'`,
738
+ `Port esperado: ${sync.port || target + 'Service'}`
739
+ )
740
+ );
741
+ }
742
+ }
743
+
744
+ setDefaultSeverities(checks, {
745
+ 'C3-001': 'info', // reference.module may come from request context (JWT/header) — not always an active integration
746
+ 'C3-002': 'error',
747
+ 'C3-003': 'warning',
748
+ 'C3-004': 'warning',
749
+ 'C3-005': 'error',
750
+ 'C3-006': 'warning',
751
+ });
752
+
753
+ return checks;
754
+ }
755
+
756
+ // ─── C4 — Audit & Traceability ───────────────────────────────────────────────
757
+
758
+ function runC4(domainConfigs, systemConfig) {
759
+ const checks = {
760
+ 'C4-001': { label: 'Entidad hija con cascade REMOVE sin audit ni soft delete (raíz con audit)', severity: 'ok', findings: [] },
761
+ 'C4-002': { label: 'Entidad raíz en módulo crítico sin audit.enabled:true', severity: 'ok', findings: [] },
762
+ 'C4-003': { label: 'Campo con datos externos tipado como String no estructurado', severity: 'ok', findings: [] },
763
+ 'C4-004': { label: 'Campo readOnly en módulo crítico que no aparece en ningún evento', severity: 'ok', findings: [] },
764
+ };
765
+
766
+ const AUDIT_FIELDS = new Set(['createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'deletedAt', 'id']);
767
+ const EXTERNAL_DATA_PATTERN = /payload|rawdata|responsedata|externaldata|rawresponse|jsondata/i;
768
+
769
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
770
+ const critical = isCriticalModule(moduleName);
771
+
772
+ // Collect all event field names across this module's produced events
773
+ const moduleEventFieldNames = new Set();
774
+ for (const agg of config.aggregates || []) {
775
+ for (const ev of agg.events || []) {
776
+ for (const f of ev.fields || []) {
777
+ moduleEventFieldNames.add(f.name);
778
+ }
779
+ }
780
+ }
781
+
782
+ for (const agg of config.aggregates || []) {
783
+ // Determine if root entity has audit
784
+ const rootEntity = (agg.entities || []).find((e) => e.isRoot);
785
+ const rootHasAudit = rootEntity && rootEntity.audit && rootEntity.audit.enabled;
786
+
787
+ // Collect enum names that have transitions — state-machine fields communicate their
788
+ // changes through event names (e.g. PaymentApprovedEvent), not as explicit fields.
789
+ // Excluded from C4-004 to avoid false positives on status fields.
790
+ const stateMachineEnumNames = new Set(
791
+ (agg.enums || [])
792
+ .filter((en) => en.transitions && en.transitions.length > 0)
793
+ .map((en) => en.name)
794
+ );
795
+
796
+ for (const entity of agg.entities || []) {
797
+ // C4-001: child entity with cascade REMOVE but no audit and no softDelete
798
+ // Only trigger when root entity has audit.enabled
799
+ if (!entity.isRoot && rootHasAudit) {
800
+ const rels = entity.relationships || [];
801
+ // Also check root relationships pointing at this entity
802
+ for (const parentEntity of agg.entities || []) {
803
+ for (const rel of parentEntity.relationships || []) {
804
+ if (rel.target && rel.target.toLowerCase() === entity.name.toLowerCase()) {
805
+ const hasCascadeRemove = (rel.cascade || []).some(
806
+ (c) => c === 'REMOVE' || c === 'ALL'
807
+ );
808
+ if (hasCascadeRemove) {
809
+ const hasAudit = entity.audit && entity.audit.enabled;
810
+ const hasSoftDelete = entity.hasSoftDelete;
811
+ if (!hasAudit && !hasSoftDelete) {
812
+ checks['C4-001'].findings.push(
813
+ finding(
814
+ moduleName,
815
+ `Entidad hija '${entity.name}' tiene cascade REMOVE pero sin audit ni soft delete`,
816
+ `Raíz '${parentEntity.name}' tiene audit habilitado. Agregado: ${agg.name}`
817
+ )
818
+ );
819
+ }
820
+ }
821
+ }
822
+ }
823
+ }
824
+ }
825
+
826
+ for (const field of entity.fields || []) {
827
+ // C6-002: root entity in critical module without audit
828
+ // (Handled per-entity outside loop below, but check root here)
829
+ // noop — done below per entity
830
+
831
+ // C4-003: field with external-data-sounding name typed as plain String in a module with ports
832
+ const hasPorts = (config.ports || []).length > 0;
833
+ if (hasPorts && field.type === 'String' && EXTERNAL_DATA_PATTERN.test(field.name)) {
834
+ checks['C4-003'].findings.push(
835
+ finding(
836
+ moduleName,
837
+ `Campo '${field.name}' almacena datos externos como String no estructurado`,
838
+ `Entidad: ${entity.name} — considerar declarar nestedType en su lugar`
839
+ )
840
+ );
841
+ }
842
+
843
+ // C4-004: readOnly field in critical module not appearing in any event.
844
+ // Excludes:
845
+ // - hidden fields (sensitive — not to be propagated in events)
846
+ // - fields with defaultValue (system constants like currency="USD")
847
+ // - state-machine fields (type is an enum with transitions — state is communicated
848
+ // implicitly by the event name, e.g. PaymentApprovedEvent implies status=APPROVED)
849
+ const isSystemConstant = field.defaultValue !== undefined && field.defaultValue !== null;
850
+ const isStateMachineField = stateMachineEnumNames.has(field.type);
851
+ if (critical && entity.isRoot && field.readOnly && !field.hidden && !isSystemConstant && !isStateMachineField && !AUDIT_FIELDS.has(field.name)) {
852
+ if (!fieldMatchesAnyEventField(field.name, moduleEventFieldNames)) {
853
+ checks['C4-004'].findings.push(
854
+ finding(
855
+ moduleName,
856
+ `Campo readOnly '${field.name}' en módulo crítico '${moduleName}' no aparece en ningún evento del módulo`,
857
+ `Entidad: ${entity.name} — considerar incluirlo en un evento o documentar por qué es privado`
858
+ )
859
+ );
860
+ }
861
+ }
862
+ }
863
+ }
864
+
865
+ // C4-002: root entity in critical module without audit.enabled
866
+ if (critical && rootEntity && !(rootEntity.audit && rootEntity.audit.enabled)) {
867
+ checks['C4-002'].findings.push(
868
+ finding(
869
+ moduleName,
870
+ `Entidad raíz '${rootEntity.name}' en módulo crítico '${moduleName}' no tiene audit.enabled:true`,
871
+ `Agregado: ${agg.name}`
872
+ )
873
+ );
874
+ }
875
+ }
876
+ }
877
+
878
+ setDefaultSeverities(checks, {
879
+ 'C4-001': 'warning',
880
+ 'C4-002': 'warning',
881
+ 'C4-003': 'warning',
882
+ 'C4-004': 'warning',
883
+ });
884
+
885
+ return checks;
886
+ }
887
+
888
+ // ── Severity finalization ────────────────────────────────────────────────────
889
+
890
+ /**
891
+ * For each check: if findings exist set its defaultSeverity, otherwise keep 'ok'.
892
+ */
893
+ function setDefaultSeverities(checks, defaults) {
894
+ for (const [id, sev] of Object.entries(defaults)) {
895
+ if (checks[id] && checks[id].findings.length > 0) {
896
+ checks[id].severity = sev;
897
+ }
898
+ }
899
+ }
900
+
901
+ // ── Main export ──────────────────────────────────────────────────────────────
902
+
903
+ /**
904
+ * @param {Record<string, object>} domainConfigs - moduleName → parsed domain YAML
905
+ * @param {object} systemConfig - parsed system.yaml
906
+ * @returns {{ summary, categories, diagrams }}
907
+ */
908
+ function validateDomain(domainConfigs, systemConfig) {
909
+ const c1Checks = runC1(domainConfigs, systemConfig);
910
+ const c2Checks = runC2(domainConfigs, systemConfig);
911
+ const c3Checks = runC3(domainConfigs, systemConfig);
912
+ const c4Checks = runC4(domainConfigs, systemConfig);
913
+
914
+ const categories = [
915
+ {
916
+ id: 'C1',
917
+ label: 'Contratos de Eventos Kafka',
918
+ description: 'Verifica que el grafo productor→consumidor esté completo y los contratos de campos sean coherentes.',
919
+ checks: checksToArray(c1Checks),
920
+ },
921
+ {
922
+ id: 'C2',
923
+ label: 'Gaps de Comportamiento',
924
+ description: 'Verifica que cada transición de estado y cada use case tenga un mecanismo de activación trazable.',
925
+ checks: checksToArray(c2Checks),
926
+ },
927
+ {
928
+ id: 'C3',
929
+ label: 'Integridad de Referencias Cruzadas',
930
+ description: 'Verifica que todas las dependencias entre módulos estén declaradas y sean coherentes en ambos lados.',
931
+ checks: checksToArray(c3Checks),
932
+ },
933
+ {
934
+ id: 'C4',
935
+ label: 'Auditoría y Trazabilidad',
936
+ description: 'Verifica que las entidades críticas tengan mecanismos de trazabilidad de cambios.',
937
+ checks: checksToArray(c4Checks),
938
+ },
939
+ ];
940
+
941
+ // Compute summary
942
+ let errors = 0, warnings = 0, info = 0, ok = 0;
943
+ for (const cat of categories) {
944
+ for (const check of cat.checks) {
945
+ if (check.severity === 'error') errors++;
946
+ else if (check.severity === 'warning') warnings++;
947
+ else if (check.severity === 'info') info++;
948
+ else ok++;
949
+ }
950
+ }
951
+
952
+ const { generateDomainDiagrams } = require('./domain-diagram');
953
+
954
+ return {
955
+ summary: { errors, warnings, info, ok },
956
+ categories,
957
+ diagrams: generateDomainDiagrams(domainConfigs),
958
+ };
959
+ }
960
+
961
+ function checksToArray(checksMap) {
962
+ return Object.entries(checksMap).map(([id, check]) => ({
963
+ id,
964
+ label: check.label,
965
+ severity: check.severity,
966
+ findings: check.findings,
967
+ }));
968
+ }
969
+
970
+ module.exports = { validateDomain };