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.
- package/AGENTS.md +314 -10
- package/COMMAND_EVALUATION.md +15 -16
- package/DOMAIN_YAML_GUIDE.md +576 -10
- package/FUTURE_FEATURES.md +1627 -1168
- package/README.md +318 -13
- package/bin/eva4j.js +34 -0
- package/config/defaults.json +1 -0
- package/design-system.md +797 -0
- package/docs/commands/EVALUATE_SYSTEM.md +994 -0
- package/docs/commands/GENERATE_ENTITIES.md +795 -6
- package/docs/commands/INDEX.md +10 -1
- package/examples/domain-endpoints-relations.yaml +353 -0
- package/examples/domain-endpoints-versioned.yaml +144 -0
- package/examples/domain-endpoints.yaml +135 -0
- package/examples/domain-events.yaml +166 -20
- package/examples/domain-listeners.yaml +212 -0
- package/examples/domain-one-to-many.yaml +1 -0
- package/examples/domain-one-to-one.yaml +1 -0
- package/examples/domain-ports.yaml +414 -0
- package/examples/domain-soft-delete.yaml +47 -44
- package/examples/system/notification.yaml +147 -0
- package/examples/system/product.yaml +185 -0
- package/examples/system/system.yaml +112 -0
- package/examples/system-report.html +971 -0
- package/examples/system.yaml +332 -0
- package/package.json +2 -1
- package/src/commands/build.js +714 -0
- package/src/commands/create.js +7 -3
- package/src/commands/detach.js +1 -0
- package/src/commands/evaluate-system.js +610 -0
- package/src/commands/generate-entities.js +1331 -49
- package/src/commands/generate-http-exchange.js +2 -0
- package/src/commands/generate-kafka-event.js +98 -11
- package/src/generators/base-generator.js +8 -1
- package/src/generators/postman-generator.js +188 -0
- package/src/generators/shared-generator.js +10 -0
- package/src/utils/config-manager.js +54 -0
- package/src/utils/context-builder.js +1 -0
- package/src/utils/domain-diagram.js +192 -0
- package/src/utils/domain-validator.js +970 -0
- package/src/utils/fake-data.js +376 -0
- package/src/utils/naming.js +3 -2
- package/src/utils/system-validator.js +434 -0
- package/src/utils/yaml-to-entity.js +302 -8
- package/templates/aggregate/AggregateMapper.java.ejs +3 -2
- package/templates/aggregate/AggregateRepository.java.ejs +8 -2
- package/templates/aggregate/AggregateRepositoryImpl.java.ejs +13 -3
- package/templates/aggregate/AggregateRoot.java.ejs +60 -2
- package/templates/aggregate/DomainEventHandler.java.ejs +27 -20
- package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
- package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
- package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
- package/templates/aggregate/JpaRepository.java.ejs +5 -0
- package/templates/base/gradle/build.gradle.ejs +3 -2
- package/templates/base/root/AGENTS.md.ejs +306 -45
- package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1663 -0
- package/templates/base/root/skill-build-system-yaml.ejs +1446 -0
- package/templates/base/root/system.yaml.ejs +97 -0
- package/templates/crud/ApplicationMapper.java.ejs +4 -0
- package/templates/crud/Controller.java.ejs +4 -4
- package/templates/crud/CreateCommand.java.ejs +4 -0
- package/templates/crud/CreateItemDto.java.ejs +4 -0
- package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
- package/templates/crud/DeleteCommandHandler.java.ejs +10 -2
- package/templates/crud/EndpointsController.java.ejs +178 -0
- package/templates/crud/FindByQuery.java.ejs +17 -0
- package/templates/crud/FindByQueryHandler.java.ejs +57 -0
- package/templates/crud/ListQuery.java.ejs +1 -1
- package/templates/crud/ListQueryHandler.java.ejs +8 -8
- package/templates/crud/ScaffoldCommand.java.ejs +12 -0
- package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
- package/templates/crud/ScaffoldQuery.java.ejs +13 -0
- package/templates/crud/ScaffoldQueryHandler.java.ejs +41 -0
- package/templates/crud/SubEntityAddCommand.java.ejs +21 -0
- package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
- package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
- package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
- package/templates/crud/TransitionCommand.java.ejs +9 -0
- package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
- package/templates/crud/UpdateCommand.java.ejs +4 -0
- package/templates/evaluate/report.html.ejs +1363 -0
- package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
- package/templates/kafka-event/Event.java.ejs +16 -0
- package/templates/kafka-listener/KafkaController.java.ejs +1 -1
- package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
- package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
- package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
- package/templates/kafka-listener/ListenerCommandHandler.java.ejs +23 -0
- package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
- package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
- package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
- package/templates/mock/MockEvent.java.ejs +10 -0
- package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
- package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
- package/templates/mock/SpringEventListener.java.ejs +61 -0
- package/templates/ports/PortDomainModel.java.ejs +35 -0
- package/templates/ports/PortFeignAdapter.java.ejs +67 -0
- package/templates/ports/PortFeignClient.java.ejs +45 -0
- package/templates/ports/PortFeignConfig.java.ejs +24 -0
- package/templates/ports/PortInterface.java.ejs +45 -0
- package/templates/ports/PortNestedType.java.ejs +28 -0
- package/templates/ports/PortRequestDto.java.ejs +30 -0
- package/templates/ports/PortResponseDto.java.ejs +28 -0
- package/templates/postman/Collection.json.ejs +1 -1
- package/templates/postman/UnifiedCollection.json.ejs +185 -0
- 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 };
|