eva4j 1.0.14 → 1.0.16

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 (139) hide show
  1. package/.agents/skills/skill-creator/LICENSE.txt +202 -0
  2. package/.agents/skills/skill-creator/SKILL.md +485 -0
  3. package/.agents/skills/skill-creator/agents/analyzer.md +274 -0
  4. package/.agents/skills/skill-creator/agents/comparator.md +202 -0
  5. package/.agents/skills/skill-creator/agents/grader.md +223 -0
  6. package/.agents/skills/skill-creator/assets/eval_review.html +146 -0
  7. package/.agents/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  8. package/.agents/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  9. package/.agents/skills/skill-creator/references/schemas.md +430 -0
  10. package/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  11. package/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  12. package/.agents/skills/skill-creator/scripts/generate_report.py +326 -0
  13. package/.agents/skills/skill-creator/scripts/improve_description.py +247 -0
  14. package/.agents/skills/skill-creator/scripts/package_skill.py +136 -0
  15. package/.agents/skills/skill-creator/scripts/quick_validate.py +103 -0
  16. package/.agents/skills/skill-creator/scripts/run_eval.py +310 -0
  17. package/.agents/skills/skill-creator/scripts/run_loop.py +328 -0
  18. package/.agents/skills/skill-creator/scripts/utils.py +47 -0
  19. package/AGENTS.md +268 -6
  20. package/COMMAND_EVALUATION.md +15 -16
  21. package/DOMAIN_YAML_GUIDE.md +430 -14
  22. package/FUTURE_FEATURES.md +1627 -1168
  23. package/README.md +461 -13
  24. package/bin/eva4j.js +32 -14
  25. package/config/defaults.json +1 -0
  26. package/docs/commands/EVALUATE_SYSTEM.md +746 -261
  27. package/docs/commands/EXPORT_DIAGRAM.md +153 -0
  28. package/docs/commands/GENERATE_ENTITIES.md +599 -6
  29. package/docs/commands/INDEX.md +7 -0
  30. package/examples/domain-events.yaml +166 -20
  31. package/examples/domain-listeners.yaml +212 -0
  32. package/examples/domain-one-to-many.yaml +1 -0
  33. package/examples/domain-one-to-one.yaml +1 -0
  34. package/examples/domain-ports.yaml +414 -0
  35. package/examples/domain-soft-delete.yaml +47 -44
  36. package/examples/system/notification.yaml +147 -0
  37. package/examples/system/product.yaml +185 -0
  38. package/examples/system/system.yaml +112 -0
  39. package/examples/system-report.html +971 -0
  40. package/examples/system.yaml +46 -3
  41. package/package.json +2 -1
  42. package/src/agents/design-reviewer.agent.md +163 -0
  43. package/src/commands/build.js +714 -0
  44. package/src/commands/create.js +1 -0
  45. package/src/commands/detach.js +149 -108
  46. package/src/commands/evaluate-system.js +234 -8
  47. package/src/commands/export-diagram.js +522 -0
  48. package/src/commands/generate-entities.js +685 -66
  49. package/src/commands/generate-http-exchange.js +2 -0
  50. package/src/commands/generate-kafka-event.js +43 -10
  51. package/src/generators/base-generator.js +18 -6
  52. package/src/generators/postman-generator.js +188 -0
  53. package/src/generators/shared-generator.js +12 -2
  54. package/src/skills/build-system-yaml/SKILL.md +323 -0
  55. package/src/skills/build-system-yaml/references/domain-yaml-spec.md +410 -0
  56. package/src/skills/build-system-yaml/references/module-spec.md +367 -0
  57. package/src/skills/build-system-yaml/references/system-yaml-spec.md +178 -0
  58. package/src/utils/config-manager.js +54 -0
  59. package/src/utils/context-builder.js +1 -0
  60. package/src/utils/domain-diagram.js +192 -0
  61. package/src/utils/domain-validator.js +1020 -0
  62. package/src/utils/fake-data.js +376 -0
  63. package/src/utils/system-validator.js +319 -199
  64. package/src/utils/yaml-to-entity.js +272 -7
  65. package/templates/aggregate/AggregateMapper.java.ejs +3 -2
  66. package/templates/aggregate/AggregateRepository.java.ejs +3 -2
  67. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +6 -5
  68. package/templates/aggregate/AggregateRoot.java.ejs +60 -2
  69. package/templates/aggregate/DomainEventHandler.java.ejs +4 -1
  70. package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
  71. package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
  72. package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
  73. package/templates/base/docker/Dockerfile.ejs +21 -0
  74. package/templates/base/gradle/build.gradle.ejs +3 -2
  75. package/templates/base/root/AGENTS.md.ejs +306 -45
  76. package/templates/crud/ApplicationMapper.java.ejs +4 -0
  77. package/templates/crud/Controller.java.ejs +4 -4
  78. package/templates/crud/CreateCommand.java.ejs +4 -0
  79. package/templates/crud/CreateCommandHandler.java.ejs +3 -6
  80. package/templates/crud/CreateItemDto.java.ejs +4 -0
  81. package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
  82. package/templates/crud/DeleteCommandHandler.java.ejs +12 -6
  83. package/templates/crud/EndpointsController.java.ejs +6 -6
  84. package/templates/crud/FindByQuery.java.ejs +1 -1
  85. package/templates/crud/FindByQueryHandler.java.ejs +3 -9
  86. package/templates/crud/GetQueryHandler.java.ejs +2 -5
  87. package/templates/crud/ListQuery.java.ejs +1 -1
  88. package/templates/crud/ListQueryHandler.java.ejs +8 -14
  89. package/templates/crud/ScaffoldCommandHandler.java.ejs +2 -4
  90. package/templates/crud/ScaffoldQuery.java.ejs +3 -2
  91. package/templates/crud/ScaffoldQueryHandler.java.ejs +5 -6
  92. package/templates/crud/SubEntityAddCommand.java.ejs +4 -0
  93. package/templates/crud/SubEntityAddCommandHandler.java.ejs +2 -6
  94. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +2 -7
  95. package/templates/crud/TransitionCommandHandler.java.ejs +2 -6
  96. package/templates/crud/UpdateCommand.java.ejs +4 -0
  97. package/templates/crud/UpdateCommandHandler.java.ejs +2 -5
  98. package/templates/evaluate/report.html.ejs +394 -2
  99. package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
  100. package/templates/kafka-event/Event.java.ejs +9 -0
  101. package/templates/kafka-listener/KafkaController.java.ejs +1 -1
  102. package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
  103. package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
  104. package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
  105. package/templates/kafka-listener/ListenerCommandHandler.java.ejs +25 -0
  106. package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
  107. package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
  108. package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
  109. package/templates/mock/MockEvent.java.ejs +10 -0
  110. package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
  111. package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
  112. package/templates/mock/SpringEventListener.java.ejs +61 -0
  113. package/templates/ports/PortDomainModel.java.ejs +35 -0
  114. package/templates/ports/PortFeignAdapter.java.ejs +67 -0
  115. package/templates/ports/PortFeignClient.java.ejs +45 -0
  116. package/templates/ports/PortFeignConfig.java.ejs +24 -0
  117. package/templates/ports/PortInterface.java.ejs +45 -0
  118. package/templates/ports/PortNestedType.java.ejs +28 -0
  119. package/templates/ports/PortRequestDto.java.ejs +30 -0
  120. package/templates/ports/PortResponseDto.java.ejs +28 -0
  121. package/templates/postman/Collection.json.ejs +1 -1
  122. package/templates/postman/UnifiedCollection.json.ejs +185 -0
  123. package/templates/shared/annotations/LogAfter.java.ejs +2 -0
  124. package/templates/shared/annotations/LogBefore.java.ejs +2 -0
  125. package/templates/shared/annotations/LogExceptions.java.ejs +2 -0
  126. package/templates/shared/annotations/LogLevel.java.ejs +8 -0
  127. package/templates/shared/annotations/LogTimer.java.ejs +1 -0
  128. package/templates/shared/annotations/Loggable.java.ejs +17 -0
  129. package/templates/shared/configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs +109 -0
  130. package/templates/shared/configurations/loggerConfig/HandlerLogs.java.ejs +120 -32
  131. package/templates/shared/errorMessage/ErrorResponse.java.ejs +23 -0
  132. package/templates/shared/handlerException/HandlerExceptions.java.ejs +99 -79
  133. package/src/commands/generate-system.js +0 -243
  134. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +0 -1103
  135. package/templates/base/root/skill-build-domain-yaml.ejs +0 -292
  136. package/templates/base/root/skill-build-system-yaml.ejs +0 -252
  137. package/templates/shared/errorMessage/ErrorMessage.java.ejs +0 -5
  138. package/templates/shared/errorMessage/FullErrorMessage.java.ejs +0 -9
  139. package/templates/shared/errorMessage/ShortErrorMessage.java.ejs +0 -6
@@ -1,14 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Validates a parsed system.yaml object against 5 architectural checks.
4
+ * Validates a parsed system.yaml object against the S1–S5 static evaluation rules.
5
5
  *
6
6
  * @param {object} systemConfig - Parsed system.yaml content
7
- * @returns {{ errors: string[], warnings: string[], ok: string[], score: number }}
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 }}
8
9
  */
9
- function validateSystem(systemConfig) {
10
+ function validateSystem(systemConfig, domainConfigs = {}) {
10
11
  const errors = [];
11
12
  const warnings = [];
13
+ const info = [];
12
14
  const ok = [];
13
15
 
14
16
  const modules = systemConfig.modules || [];
@@ -16,93 +18,206 @@ function validateSystem(systemConfig) {
16
18
  const integrations = systemConfig.integrations || {};
17
19
  const asyncEvents = integrations.async || [];
18
20
  const syncIntegrations = integrations.sync || [];
21
+ const messaging = systemConfig.messaging || {};
22
+ const topicPrefix = (messaging.kafka || {}).topicPrefix || null;
19
23
 
20
- // Build a quick lookup: module name exposes array of "METHOD /path" strings
21
- const moduleExposes = {};
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;
22
54
  for (const mod of modules) {
23
- moduleExposes[mod.name] = (mod.exposes || []).map(
24
- (ep) => `${ep.method} ${ep.path}`
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)
25
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 ✓');
26
79
  }
27
80
 
28
- // ── Check 1: Referential Integrity ────────────────────────────────────────
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;
29
89
 
30
- // 1a. Event producers
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;
31
104
  for (const ev of asyncEvents) {
32
- if (!moduleNames.has(ev.producer)) {
33
- errors.push(
34
- `Integridad referencial: el productor '${ev.producer}' del evento '${ev.event}' no está declarado en modules[]`
35
- );
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;
36
123
  } else {
37
- ok.push(`Productor '${ev.producer}' del evento '${ev.event}' existe ✓`);
124
+ topicToEvent[ev.topic] = ev.event;
38
125
  }
39
126
  }
127
+ if (!s2_002_found && asyncEvents.length > 0) {
128
+ ok.push('[S2-002] No hay colisiones de topics en integrations.async ✓');
129
+ }
40
130
 
41
- // 1b. Event consumers
131
+ // S2-003: self-loop (module consuming its own event)
132
+ let s2_003_found = false;
42
133
  for (const ev of asyncEvents) {
43
- const consumers = ev.consumers || [];
44
- for (const c of consumers) {
45
- const moduleName = typeof c === 'string' ? c : c.module;
46
- if (!moduleNames.has(moduleName)) {
47
- errors.push(
48
- `Integridad referencial: el consumidor '${moduleName}' del evento '${ev.event}' no está declarado en modules[]`
49
- );
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;
50
138
  }
51
139
  }
52
140
  }
53
- if (asyncEvents.every((ev) => (ev.consumers || []).every((c) => moduleNames.has(typeof c === 'string' ? c : c.module)))) {
54
- ok.push('Todos los consumidores de eventos están declarados como módulos ✓');
141
+ if (!s2_003_found && asyncEvents.length > 0) {
142
+ ok.push('[S2-003] No se detectaron self-loops en el grafo de eventos ✓');
55
143
  }
56
144
 
57
- // 1c. Sync integration caller/callee existence
58
- for (const sync of syncIntegrations) {
59
- if (!moduleNames.has(sync.caller)) {
60
- errors.push(
61
- `Integridad referencial: el caller '${sync.caller}' de la integración síncrona no está declarado en modules[]`
62
- );
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`);
63
153
  }
64
- if (!moduleNames.has(sync.calls)) {
65
- errors.push(
66
- `Integridad referencial: el callee '${sync.calls}' de la integración síncrona (caller: ${sync.caller}) no está declarado en modules[]`
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
67
164
  );
165
+ if (!producesDomainEvents) {
166
+ warnings.push(`[S2-005] Módulo '${mod.name}' consume eventos pero no produce ninguno`);
167
+ }
68
168
  }
69
169
  }
70
170
 
71
- // 1d. Sync endpoints exist in target module's exposes
72
- let allSyncEndpointsFound = true;
73
- for (const sync of syncIntegrations) {
74
- if (!moduleNames.has(sync.calls)) continue; // already caught above
75
- const targetExposes = moduleExposes[sync.calls] || [];
76
- for (const endpoint of sync.using || []) {
77
- const normalized = endpoint.trim().toUpperCase().replace(/\/+$/, '');
78
- const found = targetExposes.some((ep) => {
79
- const normalizedEp = ep.trim().toUpperCase().replace(/\/+$/, '');
80
- return normalizedEp === normalized || endpointMatches(ep, endpoint);
81
- });
82
- if (!found) {
83
- errors.push(
84
- `Integridad referencial: el endpoint '${endpoint}' usado por '${sync.caller}' no está declarado en el exposes[] de '${sync.calls}'`
85
- );
86
- allSyncEndpointsFound = false;
87
- }
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;
88
178
  }
89
179
  }
90
- if (allSyncEndpointsFound && syncIntegrations.length > 0) {
91
- ok.push('Todos los endpoints usados en integraciones síncronas están declarados en los módulos destino ✓');
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 ✓');
92
182
  }
93
183
 
94
- // ── Check 2: Cycle Detection (sync deps) ─────────────────────────────────
184
+ // ── S3 Integridad de llamadas síncronas ────────────────────────────────
95
185
 
96
- // Build directed graph: A B when caller=A, calls=B
97
- const syncGraph = {};
186
+ // S3-001: sync call to module with no exposes
187
+ let s3_001_found = false;
98
188
  for (const sync of syncIntegrations) {
99
- if (!syncGraph[sync.caller]) syncGraph[sync.caller] = [];
100
- syncGraph[sync.caller].push(sync.calls);
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 ✓');
101
216
  }
102
217
 
103
- // Detect strict bidirectional sync coupling (A→B and B→A)
218
+ // S3-003: bidirectional sync coupling (WARNING, not error)
104
219
  const biDirChecked = new Set();
105
- let biDirFound = false;
220
+ let s3_003_found = false;
106
221
  for (const sync of syncIntegrations) {
107
222
  const key = [sync.caller, sync.calls].sort().join('↔');
108
223
  if (biDirChecked.has(key)) continue;
@@ -111,189 +226,194 @@ function validateSystem(systemConfig) {
111
226
  (s) => s.caller === sync.calls && s.calls === sync.caller
112
227
  );
113
228
  if (reverse) {
114
- errors.push(
115
- `Acoplamiento circular síncrono: '${sync.caller}' y '${sync.calls}' se llaman mutuamente de forma síncrona. Esto puede causar deadlocks.`
116
- );
117
- biDirFound = true;
229
+ warnings.push(`[S3-003] Acoplamiento síncrono bidireccional: '${sync.caller}' llama a '${sync.calls}' y viceversa`);
230
+ s3_003_found = true;
118
231
  }
119
232
  }
233
+ if (!s3_003_found && syncIntegrations.length > 0) {
234
+ ok.push('[S3-003] No se detectó acoplamiento síncrono bidireccional ✓');
235
+ }
120
236
 
121
- // DFS for longer cycles
122
- function detectCycle(startNode) {
123
- const visited = new Set();
124
- function dfs(node, path) {
125
- if (path.includes(node)) return path.concat(node);
126
- if (visited.has(node)) return null;
127
- visited.add(node);
128
- for (const neighbor of syncGraph[node] || []) {
129
- const result = dfs(neighbor, path.concat(node));
130
- if (result) return result;
131
- }
132
- return null;
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(', ')}`);
133
246
  }
134
- return dfs(startNode, []);
135
- }
136
-
137
- const cycleChecked = new Set();
138
- let cycleFound = false;
139
- for (const node of Object.keys(syncGraph)) {
140
- if (cycleChecked.has(node)) continue;
141
- const cycle = detectCycle(node);
142
- if (cycle && cycle.length > 2) {
143
- const cycleStr = cycle.join(' → ');
144
- errors.push(`Ciclo síncrono detectado: ${cycleStr}`);
145
- cycle.forEach((n) => cycleChecked.add(n));
146
- cycleFound = true;
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`);
147
255
  }
148
256
  }
149
257
 
150
- if (!biDirFound && !cycleFound) {
151
- ok.push('No se detectaron ciclos ni acoplamiento síncrono bidireccional ✓');
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 ✓');
152
284
  }
153
285
 
154
- // ── Check 3: Role Analysis ────────────────────────────────────────────────
286
+ // ── S4 Coherencia de endpoints ─────────────────────────────────────────
155
287
 
156
288
  for (const mod of modules) {
157
- const hasExposes = (mod.exposes || []).length > 0;
158
- const producesEvents = asyncEvents.some((e) => e.producer === mod.name);
159
- const consumesEvents = asyncEvents.some((e) =>
160
- (e.consumers || []).some((c) => (typeof c === 'string' ? c : c.module) === mod.name)
161
- );
162
- const makesSyncCalls = syncIntegrations.some((s) => s.caller === mod.name);
163
- const receivesSyncCalls = syncIntegrations.some((s) => s.calls === mod.name);
164
-
165
- const hasAnyIntegration = producesEvents || consumesEvents || makesSyncCalls || receivesSyncCalls;
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
+ }
166
301
 
167
- if (!hasExposes && !hasAnyIntegration) {
168
- warnings.push(
169
- `Módulo aislado: '${mod.name}' no tiene endpoints expuestos ni integraciones declaradas`
170
- );
171
- } else if (!hasExposes) {
172
- warnings.push(
173
- `'${mod.name}' no tiene endpoints expuestos (exposes[] vacío o ausente)`
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
174
312
  );
175
- } else if (!hasAnyIntegration) {
176
- ok.push(`'${mod.name}' es un módulo autónomo sin dependencias de integración`);
313
+ if (!hasGet) {
314
+ warnings.push(`[S4-002] Módulo '${mod.name}' tiene PUT ${ep.path} sin el correspondiente GET ${ep.path}`);
315
+ }
177
316
  }
178
317
 
179
- // Check: module that only consumes expected, no warning
180
- if (!producesEvents && consumesEvents && !makesSyncCalls) {
181
- ok.push(`'${mod.name}' es consumidor puro de eventos (correcto: no produce eventos propios)`);
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
+ }
182
324
  }
183
- }
184
-
185
- // ── Check 4: Behavior Gaps ─────────────────────────────────────────────────
186
325
 
187
- const schedulerVerbs = ['expire', 'clean', 'close', 'archive', 'timeout', 'process', 'purge', 'flush'];
188
- const mutationMethods = new Set(['PUT', 'PATCH', 'DELETE', 'POST']);
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
+ }
189
332
 
190
- for (const mod of modules) {
191
- for (const ep of mod.exposes || []) {
192
- const useCaseLower = (ep.useCase || '').toLowerCase();
193
- const method = (ep.method || '').toUpperCase();
194
-
195
- if (!mutationMethods.has(method)) continue;
196
-
197
- const matchedVerb = schedulerVerbs.find((v) => useCaseLower.startsWith(v) || useCaseLower.includes(v));
198
- if (!matchedVerb) continue;
199
-
200
- // Check: is this endpoint reachable via an async event
201
- const triggeredByEvent = asyncEvents.some((ev) =>
202
- (ev.consumers || []).some((c) => {
203
- const consumer = typeof c === 'string' ? c : c.module;
204
- if (consumer !== mod.name) return false;
205
- // The event name often matches the use case verb
206
- const eventLower = ev.event.toLowerCase();
207
- return schedulerVerbs.some((v) => eventLower.includes(v));
208
- })
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 || '')
209
339
  );
210
-
211
- // Check: is this endpoint called by a sync integration
212
- const triggeredBySync = syncIntegrations.some((s) => s.calls === mod.name && (s.using || []).some((u) => u.includes(ep.path)));
213
-
214
- if (!triggeredByEvent && !triggeredBySync) {
215
- warnings.push(
216
- `Gap de comportamiento: '${ep.useCase}' (${ep.method} ${ep.path}) en '${mod.name}' no tiene ningún evento ni llamada síncrona que lo active. Puede necesitar un scheduler o job periódico.`
217
- );
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`);
218
342
  }
219
343
  }
220
344
  }
221
345
 
222
- // Check: modules with no exposes at all (already partially covered above, but surface separately)
223
- for (const mod of modules) {
224
- if (!mod.exposes || mod.exposes.length === 0) {
225
- ok.push(`'${mod.name}' no expone endpoints REST directamente (módulo de integración)`);
226
- }
227
- }
346
+ // ── S5 Coherencia del sistema global ───────────────────────────────────
228
347
 
229
- // ── Check 5: Coupling Patterns ────────────────────────────────────────────
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
+ }
230
354
 
231
- for (const sync of syncIntegrations) {
232
- const caller = sync.caller;
233
- const callee = sync.calls;
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'];
234
361
 
235
- // Find reverse async: callee publishes event that caller consumes
236
- const reverseAsyncEvents = asyncEvents.filter((ev) => {
237
- if (ev.producer !== callee) return false;
238
- return (ev.consumers || []).some((c) => (typeof c === 'string' ? c : c.module) === caller);
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));
239
377
  });
240
378
 
241
- if (reverseAsyncEvents.length > 0) {
242
- const eventNames = reverseAsyncEvents.map((e) => e.event).join(', ');
243
- warnings.push(
244
- `Acoplamiento asimétrico: '${caller}' llama síncronamente a '${callee}', mientras '${callee}' responde vía eventos asíncronos (${eventNames}). Considerar pasar los datos necesarios directamente en el evento para eliminar la llamada síncrona.`
245
- );
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
+ }
246
386
  }
247
387
  }
248
388
 
249
- // Detect dual trigger: endpoint appears in both sync.using and as event-triggered consumer
389
+ // S5-003: auth/security module with no integrations (info)
390
+ const authPattern = /auth|security|identity|session/i;
250
391
  for (const mod of modules) {
251
- for (const ep of mod.exposes || []) {
252
- const endpointStr = `${ep.method} ${ep.path}`;
253
- const inSync = syncIntegrations.some(
254
- (s) => s.calls === mod.name && (s.using || []).some((u) => endpointMatches(endpointStr, u) || endpointMatches(u, endpointStr))
255
- );
256
- const inEvents = asyncEvents.some(
257
- (ev) =>
258
- (ev.consumers || []).some((c) => (typeof c === 'string' ? c : c.module) === mod.name) &&
259
- asyncEvents.some(() => false) // placeholder; real dual-trigger needs business knowledge
260
- );
261
- // Flag endpoints used in sync AND the module also consumes events (approximate heuristic)
262
- if (inSync) {
263
- const modConsumesEvents = asyncEvents.some((ev) =>
264
- (ev.consumers || []).some((c) => (typeof c === 'string' ? c : c.module) === mod.name)
265
- );
266
- if (modConsumesEvents) {
267
- ok.push(
268
- `'${mod.name}' tiene endpoints accesibles tanto síncronamente como vía eventos (diseño dual — intencional)`
269
- );
270
- break; // one ok per module is enough
271
- }
272
- }
273
- void inEvents; // suppress unused warning
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`);
274
398
  }
275
399
  }
276
400
 
277
- // Highlight producer-only modules (no event consumption, no sync calls received) — healthy pattern
401
+ // S5-004: module with no connection to system graph (info)
278
402
  for (const mod of modules) {
279
- const onlyProduces =
280
- asyncEvents.some((e) => e.producer === mod.name) &&
281
- !asyncEvents.some((e) =>
282
- (e.consumers || []).some((c) => (typeof c === 'string' ? c : c.module) === mod.name)
283
- ) &&
284
- !syncIntegrations.some((s) => s.calls === mod.name);
285
-
286
- if (onlyProduces) {
287
- ok.push(`'${mod.name}' es productor puro de eventos sin dependencias entrantes (bajo acoplamiento) ✓`);
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`);
288
408
  }
289
409
  }
290
410
 
291
- // ── Score ────────────────────────────────────────────────────────────────
411
+ // ── Score (info items do not affect score) ────────────────────────────────
292
412
 
293
413
  const total = ok.length + errors.length + warnings.length * 0.5;
294
414
  const score = total > 0 ? Math.round((ok.length / total) * 100) : 100;
295
415
 
296
- return { errors, warnings, ok, score };
416
+ return { errors, warnings, info, ok, score };
297
417
  }
298
418
 
299
419
  /**