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,434 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Validates a parsed system.yaml object against the S1–S5 static evaluation rules.
5
+ *
6
+ * @param {object} systemConfig - Parsed system.yaml content
7
+ * @param {Record<string, object>} [domainConfigs={}] - moduleName → parsed domain YAML (used to check domain-level events)
8
+ * @returns {{ errors: string[], warnings: string[], info: string[], ok: string[], score: number }}
9
+ */
10
+ function validateSystem(systemConfig, domainConfigs = {}) {
11
+ const errors = [];
12
+ const warnings = [];
13
+ const info = [];
14
+ const ok = [];
15
+
16
+ const modules = systemConfig.modules || [];
17
+ const moduleNames = new Set(modules.map((m) => m.name));
18
+ const integrations = systemConfig.integrations || {};
19
+ const asyncEvents = integrations.async || [];
20
+ const syncIntegrations = integrations.sync || [];
21
+ const messaging = systemConfig.messaging || {};
22
+ const topicPrefix = (messaging.kafka || {}).topicPrefix || null;
23
+
24
+ // Helper: normalize a consumer entry to its module name string
25
+ const consumerModule = (c) => (typeof c === 'string' ? c : c.module);
26
+
27
+ // ── S1 — Integridad de módulos ────────────────────────────────────────────
28
+
29
+ // Collect all module names referenced in integrations
30
+ const referencedInIntegrations = new Set();
31
+ for (const ev of asyncEvents) {
32
+ if (ev.producer) referencedInIntegrations.add(ev.producer);
33
+ for (const c of ev.consumers || []) referencedInIntegrations.add(consumerModule(c));
34
+ }
35
+ for (const sync of syncIntegrations) {
36
+ if (sync.caller) referencedInIntegrations.add(sync.caller);
37
+ if (sync.calls) referencedInIntegrations.add(sync.calls);
38
+ }
39
+
40
+ // S1-001: module referenced but not declared
41
+ let s1_001_found = false;
42
+ for (const ref of referencedInIntegrations) {
43
+ if (!moduleNames.has(ref)) {
44
+ errors.push(`[S1-001] Módulo '${ref}' referenciado en integrations pero no declarado en modules[]`);
45
+ s1_001_found = true;
46
+ }
47
+ }
48
+ if (!s1_001_found) {
49
+ ok.push('[S1-001] Todos los módulos referenciados en integrations están declarados en modules[] ✓');
50
+ }
51
+
52
+ // S1-002: module with no responsibilities
53
+ let s1_002_found = false;
54
+ for (const mod of modules) {
55
+ const hasExposes = (mod.exposes || []).length > 0;
56
+ const producesEvents = asyncEvents.some((e) => e.producer === mod.name);
57
+ const consumesEvents = asyncEvents.some((e) =>
58
+ (e.consumers || []).some((c) => consumerModule(c) === mod.name)
59
+ );
60
+ if (!hasExposes && !producesEvents && !consumesEvents) {
61
+ errors.push(`[S1-002] Módulo '${mod.name}' no tiene ninguna responsabilidad — no expone endpoints, no produce ni consume eventos`);
62
+ s1_002_found = true;
63
+ }
64
+ }
65
+ if (!s1_002_found) {
66
+ ok.push('[S1-002] Todos los módulos tienen al menos una responsabilidad declarada ✓');
67
+ }
68
+
69
+ // S1-003: module without description
70
+ let s1_003_found = false;
71
+ for (const mod of modules) {
72
+ if (!mod.description || mod.description.trim() === '') {
73
+ warnings.push(`[S1-003] Módulo '${mod.name}' no tiene campo description declarado`);
74
+ s1_003_found = true;
75
+ }
76
+ }
77
+ if (!s1_003_found) {
78
+ ok.push('[S1-003] Todos los módulos tienen description declarado ✓');
79
+ }
80
+
81
+ // S1-004: purely reactive module not documented
82
+ for (const mod of modules) {
83
+ const producesEvents = asyncEvents.some((e) => e.producer === mod.name);
84
+ const consumesEvents = asyncEvents.some((e) =>
85
+ (e.consumers || []).some((c) => consumerModule(c) === mod.name)
86
+ );
87
+ const makesSyncCalls = syncIntegrations.some((s) => s.caller === mod.name);
88
+ const hasExposes = (mod.exposes || []).length > 0;
89
+
90
+ const isPurelyReactive = consumesEvents && !producesEvents && !makesSyncCalls && !hasExposes;
91
+ if (isPurelyReactive) {
92
+ const desc = (mod.description || '').toLowerCase();
93
+ const documentedAsReactive = desc.includes('consume') || desc.includes('reactiv') || desc.includes('event') || desc.includes('suscri') || desc.includes('listen');
94
+ if (!documentedAsReactive) {
95
+ info.push(`[S1-004] Módulo '${mod.name}' es puramente reactivo (solo consume eventos) pero su description no lo documenta explícitamente`);
96
+ }
97
+ }
98
+ }
99
+
100
+ // ── S2 — Integridad del grafo de eventos async ────────────────────────────
101
+
102
+ // S2-001: event with no consumers
103
+ let s2_001_found = false;
104
+ for (const ev of asyncEvents) {
105
+ const consumers = ev.consumers || [];
106
+ if (consumers.length === 0) {
107
+ errors.push(`[S2-001] Evento '${ev.event}' declarado en integrations.async sin consumidores`);
108
+ s2_001_found = true;
109
+ }
110
+ }
111
+ if (!s2_001_found && asyncEvents.length > 0) {
112
+ ok.push('[S2-001] Todos los eventos async tienen al menos un consumidor declarado ✓');
113
+ }
114
+
115
+ // S2-002: duplicate topic values
116
+ const topicToEvent = {};
117
+ let s2_002_found = false;
118
+ for (const ev of asyncEvents) {
119
+ if (!ev.topic) continue;
120
+ if (topicToEvent[ev.topic]) {
121
+ errors.push(`[S2-002] Topic '${ev.topic}' está declarado para dos eventos distintos: '${topicToEvent[ev.topic]}' y '${ev.event}'`);
122
+ s2_002_found = true;
123
+ } else {
124
+ topicToEvent[ev.topic] = ev.event;
125
+ }
126
+ }
127
+ if (!s2_002_found && asyncEvents.length > 0) {
128
+ ok.push('[S2-002] No hay colisiones de topics en integrations.async ✓');
129
+ }
130
+
131
+ // S2-003: self-loop (module consuming its own event)
132
+ let s2_003_found = false;
133
+ for (const ev of asyncEvents) {
134
+ for (const c of ev.consumers || []) {
135
+ if (consumerModule(c) === ev.producer) {
136
+ errors.push(`[S2-003] Módulo '${ev.producer}' está listado como consumidor de su propio evento '${ev.event}' (self-loop)`);
137
+ s2_003_found = true;
138
+ }
139
+ }
140
+ }
141
+ if (!s2_003_found && asyncEvents.length > 0) {
142
+ ok.push('[S2-003] No se detectaron self-loops en el grafo de eventos ✓');
143
+ }
144
+
145
+ // S2-004: module produces but never consumes
146
+ const producerSet = new Set(asyncEvents.map((e) => e.producer).filter(Boolean));
147
+ const consumerSet = new Set(
148
+ asyncEvents.flatMap((e) => (e.consumers || []).map(consumerModule))
149
+ );
150
+ for (const mod of modules) {
151
+ if (producerSet.has(mod.name) && !consumerSet.has(mod.name)) {
152
+ warnings.push(`[S2-004] Módulo '${mod.name}' produce eventos pero no consume ninguno`);
153
+ }
154
+ }
155
+
156
+ // S2-005: module consumes but never produces
157
+ // Also check domain-level events[] — a module may produce Domain Events not yet
158
+ // wired in system.yaml integrations.async[], which is a valid design-in-progress.
159
+ for (const mod of modules) {
160
+ if (consumerSet.has(mod.name) && !producerSet.has(mod.name)) {
161
+ const domainCfg = domainConfigs[mod.name];
162
+ const producesDomainEvents = (domainCfg?.aggregates || []).some(
163
+ (agg) => (agg.events || []).length > 0
164
+ );
165
+ if (!producesDomainEvents) {
166
+ warnings.push(`[S2-005] Módulo '${mod.name}' consume eventos pero no produce ninguno`);
167
+ }
168
+ }
169
+ }
170
+
171
+ // S2-006: event name not following PascalCase + Event suffix
172
+ const eventNameRegex = /^[A-Z][a-zA-Z0-9]*Event$/;
173
+ let s2_006_found = false;
174
+ for (const ev of asyncEvents) {
175
+ if (ev.event && !eventNameRegex.test(ev.event)) {
176
+ warnings.push(`[S2-006] Nombre de evento '${ev.event}' no sigue la convención PascalCase con sufijo 'Event'`);
177
+ s2_006_found = true;
178
+ }
179
+ }
180
+ if (!s2_006_found && asyncEvents.length > 0) {
181
+ ok.push('[S2-006] Todos los nombres de eventos siguen la convención PascalCase + sufijo Event ✓');
182
+ }
183
+
184
+ // ── S3 — Integridad de llamadas síncronas ────────────────────────────────
185
+
186
+ // S3-001: sync call to module with no exposes
187
+ let s3_001_found = false;
188
+ for (const sync of syncIntegrations) {
189
+ if (!moduleNames.has(sync.calls)) continue; // already caught by S1-001
190
+ const targetMod = modules.find((m) => m.name === sync.calls);
191
+ if (targetMod && (!targetMod.exposes || targetMod.exposes.length === 0)) {
192
+ errors.push(`[S3-001] '${sync.caller}' llama síncronamente a '${sync.calls}' pero este módulo no declara exposes[]`);
193
+ s3_001_found = true;
194
+ }
195
+ }
196
+ if (!s3_001_found && syncIntegrations.length > 0) {
197
+ ok.push('[S3-001] Todos los módulos destino de llamadas síncronas tienen endpoints expuestos ✓');
198
+ }
199
+
200
+ // S3-002: endpoint in using[] not found in target exposes[]
201
+ let s3_002_found = false;
202
+ for (const sync of syncIntegrations) {
203
+ if (!moduleNames.has(sync.calls)) continue;
204
+ const targetMod = modules.find((m) => m.name === sync.calls);
205
+ const targetExposes = (targetMod?.exposes || []).map((ep) => `${ep.method} ${ep.path}`);
206
+ for (const endpoint of sync.using || []) {
207
+ const found = targetExposes.some((ep) => endpointMatches(ep, endpoint));
208
+ if (!found) {
209
+ errors.push(`[S3-002] Endpoint '${endpoint}' usado por '${sync.caller}' no está declarado en exposes[] de '${sync.calls}'`);
210
+ s3_002_found = true;
211
+ }
212
+ }
213
+ }
214
+ if (!s3_002_found && syncIntegrations.length > 0) {
215
+ ok.push('[S3-002] Todos los endpoints referenciados en llamadas síncronas existen en el módulo destino ✓');
216
+ }
217
+
218
+ // S3-003: bidirectional sync coupling (WARNING, not error)
219
+ const biDirChecked = new Set();
220
+ let s3_003_found = false;
221
+ for (const sync of syncIntegrations) {
222
+ const key = [sync.caller, sync.calls].sort().join('↔');
223
+ if (biDirChecked.has(key)) continue;
224
+ biDirChecked.add(key);
225
+ const reverse = syncIntegrations.find(
226
+ (s) => s.caller === sync.calls && s.calls === sync.caller
227
+ );
228
+ if (reverse) {
229
+ warnings.push(`[S3-003] Acoplamiento síncrono bidireccional: '${sync.caller}' llama a '${sync.calls}' y viceversa`);
230
+ s3_003_found = true;
231
+ }
232
+ }
233
+ if (!s3_003_found && syncIntegrations.length > 0) {
234
+ ok.push('[S3-003] No se detectó acoplamiento síncrono bidireccional ✓');
235
+ }
236
+
237
+ // S3-004: module with more than 3 distinct outgoing sync dependencies
238
+ const outgoingSyncDeps = {};
239
+ for (const sync of syncIntegrations) {
240
+ if (!outgoingSyncDeps[sync.caller]) outgoingSyncDeps[sync.caller] = new Set();
241
+ outgoingSyncDeps[sync.caller].add(sync.calls);
242
+ }
243
+ for (const [caller, deps] of Object.entries(outgoingSyncDeps)) {
244
+ if (deps.size > 3) {
245
+ warnings.push(`[S3-004] Módulo '${caller}' tiene ${deps.size} dependencias síncronas salientes distintas (>${3}): ${[...deps].join(', ')}`);
246
+ }
247
+ }
248
+
249
+ // S3-005: module consulted synchronously but emits no events
250
+ const syncCallees = new Set(syncIntegrations.map((s) => s.calls).filter(Boolean));
251
+ for (const callee of syncCallees) {
252
+ const producesAny = asyncEvents.some((e) => e.producer === callee);
253
+ if (!producesAny) {
254
+ info.push(`[S3-005] Módulo '${callee}' es consultado síncronamente pero no emite ningún evento cuando su estado cambia`);
255
+ }
256
+ }
257
+
258
+ // S3-006: duplicate port name across different caller modules → ConflictingBeanDefinitionException
259
+ let s3_006_found = false;
260
+ const portsByName = {};
261
+ for (const sync of syncIntegrations) {
262
+ const portName = sync.port;
263
+ if (!portName) continue;
264
+ if (!portsByName[portName]) portsByName[portName] = [];
265
+ portsByName[portName].push(sync.caller);
266
+ }
267
+ for (const [portName, callers] of Object.entries(portsByName)) {
268
+ const uniqueCallers = [...new Set(callers)];
269
+ if (uniqueCallers.length > 1) {
270
+ const suggestions = uniqueCallers.map((c) => {
271
+ const prefix = c.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
272
+ return `${prefix[0].toUpperCase() + prefix.slice(1)}${portName}`;
273
+ }).join(', ');
274
+ errors.push(
275
+ `[S3-006] Port '${portName}' es usado por módulos distintos (${uniqueCallers.join(', ')}). ` +
276
+ `Esto causa ConflictingBeanDefinitionException en Spring. ` +
277
+ `Cada módulo debe usar un nombre propio: ej. ${suggestions}`
278
+ );
279
+ s3_006_found = true;
280
+ }
281
+ }
282
+ if (!s3_006_found && syncIntegrations.length > 0) {
283
+ ok.push('[S3-006] No hay nombres de port duplicados entre módulos distintos ✓');
284
+ }
285
+
286
+ // ── S4 — Coherencia de endpoints ─────────────────────────────────────────
287
+
288
+ for (const mod of modules) {
289
+ const exposes = mod.exposes || [];
290
+
291
+ // S4-001: duplicate METHOD + path within same module
292
+ const endpointKeys = new Set();
293
+ for (const ep of exposes) {
294
+ const key = `${(ep.method || '').toUpperCase()} ${ep.path || ''}`;
295
+ if (endpointKeys.has(key)) {
296
+ errors.push(`[S4-001] Módulo '${mod.name}' tiene dos endpoints con el mismo método y path: ${key}`);
297
+ } else {
298
+ endpointKeys.add(key);
299
+ }
300
+ }
301
+
302
+ // S4-002: PUT /{id} without GET /{id} for same resource base
303
+ for (const ep of exposes) {
304
+ if ((ep.method || '').toUpperCase() !== 'PUT') continue;
305
+ // Normalize path param to detect /{id} pattern
306
+ const normalizedPut = (ep.path || '').replace(/\{[^}]+\}$/, '{id}');
307
+ if (!normalizedPut.match(/\{id\}$/)) continue; // only check PUT /{id} style paths
308
+ const resourceBase = normalizedPut.replace(/\{id\}$/, '{id}');
309
+ const hasGet = exposes.some(
310
+ (g) => (g.method || '').toUpperCase() === 'GET' &&
311
+ (g.path || '').replace(/\{[^}]+\}$/, '{id}') === resourceBase
312
+ );
313
+ if (!hasGet) {
314
+ warnings.push(`[S4-002] Módulo '${mod.name}' tiene PUT ${ep.path} sin el correspondiente GET ${ep.path}`);
315
+ }
316
+ }
317
+
318
+ // S4-003: DELETE without description documenting physical vs logical
319
+ for (const ep of exposes) {
320
+ if ((ep.method || '').toUpperCase() !== 'DELETE') continue;
321
+ if (!ep.description || ep.description.trim() === '') {
322
+ warnings.push(`[S4-003] Endpoint DELETE ${ep.path} en '${mod.name}' no tiene description que indique si el borrado es físico o lógico`);
323
+ }
324
+ }
325
+
326
+ // S4-004: endpoint without description (info)
327
+ for (const ep of exposes) {
328
+ if (!ep.description || ep.description.trim() === '') {
329
+ info.push(`[S4-004] Endpoint ${ep.method} ${ep.path} en '${mod.name}' no tiene campo description`);
330
+ }
331
+ }
332
+
333
+ // S4-005: module with POST but no GET /{id} (info)
334
+ const hasPost = exposes.some((ep) => (ep.method || '').toUpperCase() === 'POST');
335
+ if (hasPost) {
336
+ const hasGetById = exposes.some(
337
+ (ep) => (ep.method || '').toUpperCase() === 'GET' &&
338
+ /\{[^}]+\}$/.test(ep.path || '')
339
+ );
340
+ if (!hasGetById) {
341
+ info.push(`[S4-005] Módulo '${mod.name}' tiene POST de creación pero no declara GET /{id} para recuperar el recurso creado`);
342
+ }
343
+ }
344
+ }
345
+
346
+ // ── S5 — Coherencia del sistema global ───────────────────────────────────
347
+
348
+ // S5-001: messaging.enabled: false with async events declared
349
+ if (messaging.enabled === false && asyncEvents.length > 0) {
350
+ warnings.push(`[S5-001] messaging.enabled está en false pero hay ${asyncEvents.length} eventos declarados en integrations.async`);
351
+ } else if (messaging.enabled !== false && asyncEvents.length > 0) {
352
+ ok.push('[S5-001] Configuración de messaging es coherente con los eventos declarados ✓');
353
+ }
354
+
355
+ // S5-002: success event without matching failure event for same subject
356
+ // Suffixes that represent external operations with side-effects → warning if no failure counterpart
357
+ const successSuffixesWarning = ['confirmedevent', 'approvedevent', 'placedevent', 'activatedevent'];
358
+ // Suffixes that represent physical/irreversible facts → info only (compensation less expected)
359
+ const successSuffixesInfo = ['completedevent'];
360
+ const failureSuffixes = ['failedevent', 'rejectedevent', 'cancelledevent', 'canceledevent', 'expiredevent'];
361
+
362
+ for (const ev of asyncEvents) {
363
+ const evLower = (ev.event || '').toLowerCase();
364
+ const matchedWarning = successSuffixesWarning.find((s) => evLower.endsWith(s));
365
+ const matchedInfo = !matchedWarning && successSuffixesInfo.find((s) => evLower.endsWith(s));
366
+ const matched = matchedWarning || matchedInfo;
367
+ if (!matched) continue;
368
+
369
+ // Derive subject: strip the matched suffix
370
+ const subjectLength = evLower.length - matched.length;
371
+ const subject = evLower.slice(0, subjectLength);
372
+
373
+ // Check if there's any failure event with the same subject prefix
374
+ const hasFailure = asyncEvents.some((other) => {
375
+ const otherLower = (other.event || '').toLowerCase();
376
+ return failureSuffixes.some((f) => otherLower.endsWith(f) && otherLower.startsWith(subject));
377
+ });
378
+
379
+ if (!hasFailure) {
380
+ const msg = `[S5-002] Evento de éxito '${ev.event}' existe pero no hay un evento de fallo correspondiente para el sujeto '${subject}' que permita compensación`;
381
+ if (matchedWarning) {
382
+ warnings.push(msg);
383
+ } else {
384
+ info.push(msg);
385
+ }
386
+ }
387
+ }
388
+
389
+ // S5-003: auth/security module with no integrations (info)
390
+ const authPattern = /auth|security|identity|session/i;
391
+ for (const mod of modules) {
392
+ if (!authPattern.test(mod.name)) continue;
393
+ const hasAnyIntegration =
394
+ asyncEvents.some((e) => e.producer === mod.name || (e.consumers || []).some((c) => consumerModule(c) === mod.name)) ||
395
+ syncIntegrations.some((s) => s.caller === mod.name || s.calls === mod.name);
396
+ if (!hasAnyIntegration) {
397
+ info.push(`[S5-003] Módulo '${mod.name}' parece manejar autenticación/seguridad pero no tiene ninguna integración declarada con otros módulos`);
398
+ }
399
+ }
400
+
401
+ // S5-004: module with no connection to system graph (info)
402
+ for (const mod of modules) {
403
+ const hasAnyConnection =
404
+ asyncEvents.some((e) => e.producer === mod.name || (e.consumers || []).some((c) => consumerModule(c) === mod.name)) ||
405
+ syncIntegrations.some((s) => s.caller === mod.name || s.calls === mod.name);
406
+ if (!hasAnyConnection) {
407
+ info.push(`[S5-004] Módulo '${mod.name}' no tiene ninguna conexión al grafo del sistema — ni async ni sync`);
408
+ }
409
+ }
410
+
411
+ // ── Score (info items do not affect score) ────────────────────────────────
412
+
413
+ const total = ok.length + errors.length + warnings.length * 0.5;
414
+ const score = total > 0 ? Math.round((ok.length / total) * 100) : 100;
415
+
416
+ return { errors, warnings, info, ok, score };
417
+ }
418
+
419
+ /**
420
+ * Compares two HTTP endpoint strings with path param normalization.
421
+ * e.g. "GET /screenings/{id}/seats" matches "GET /screenings/{id}/seats"
422
+ * Path params ({anything}) are treated as wildcards.
423
+ */
424
+ function endpointMatches(declared, used) {
425
+ const normalizePath = (str) =>
426
+ str
427
+ .trim()
428
+ .toUpperCase()
429
+ .replace(/\{[^}]+\}/g, '{*}')
430
+ .replace(/\/+$/, '');
431
+ return normalizePath(declared) === normalizePath(used);
432
+ }
433
+
434
+ module.exports = { validateSystem };