eva4j 1.0.13 → 1.0.14

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 (44) hide show
  1. package/AGENTS.md +51 -9
  2. package/DOMAIN_YAML_GUIDE.md +150 -0
  3. package/bin/eva4j.js +31 -1
  4. package/design-system.md +797 -0
  5. package/docs/commands/EVALUATE_SYSTEM.md +542 -0
  6. package/docs/commands/GENERATE_ENTITIES.md +196 -0
  7. package/docs/commands/INDEX.md +10 -1
  8. package/examples/domain-endpoints-relations.yaml +353 -0
  9. package/examples/domain-endpoints-versioned.yaml +144 -0
  10. package/examples/domain-endpoints.yaml +135 -0
  11. package/examples/system.yaml +289 -0
  12. package/package.json +1 -1
  13. package/src/commands/create.js +6 -3
  14. package/src/commands/evaluate-system.js +384 -0
  15. package/src/commands/generate-entities.js +677 -14
  16. package/src/commands/generate-kafka-event.js +59 -5
  17. package/src/commands/generate-system.js +243 -0
  18. package/src/generators/base-generator.js +9 -1
  19. package/src/utils/naming.js +3 -2
  20. package/src/utils/system-validator.js +314 -0
  21. package/src/utils/yaml-to-entity.js +31 -2
  22. package/templates/aggregate/AggregateRepository.java.ejs +5 -0
  23. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +9 -0
  24. package/templates/aggregate/DomainEventHandler.java.ejs +24 -20
  25. package/templates/aggregate/JpaRepository.java.ejs +5 -0
  26. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1103 -0
  27. package/templates/base/root/skill-build-domain-yaml.ejs +292 -0
  28. package/templates/base/root/skill-build-system-yaml.ejs +252 -0
  29. package/templates/base/root/system.yaml.ejs +97 -0
  30. package/templates/crud/EndpointsController.java.ejs +178 -0
  31. package/templates/crud/FindByQuery.java.ejs +17 -0
  32. package/templates/crud/FindByQueryHandler.java.ejs +57 -0
  33. package/templates/crud/ScaffoldCommand.java.ejs +12 -0
  34. package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
  35. package/templates/crud/ScaffoldQuery.java.ejs +12 -0
  36. package/templates/crud/ScaffoldQueryHandler.java.ejs +40 -0
  37. package/templates/crud/SubEntityAddCommand.java.ejs +17 -0
  38. package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
  39. package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
  40. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
  41. package/templates/crud/TransitionCommand.java.ejs +9 -0
  42. package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
  43. package/templates/evaluate/report.html.ejs +971 -0
  44. package/templates/kafka-event/Event.java.ejs +7 -0
@@ -151,7 +151,7 @@ async function generateKafkaEventCommand(moduleName, eventName) {
151
151
  try {
152
152
  for (const name of eventNames) {
153
153
  const normalizedName = toPascalCase(name);
154
- const evtClassName = normalizedName.endsWith('Event') ? normalizedName : `${normalizedName}Event`;
154
+ const evtClassName = normalizedName.endsWith('IntegrationEvent') ? normalizedName : `${normalizedName}IntegrationEvent`;
155
155
 
156
156
  // In batch mode skip already-existing events; in single mode abort
157
157
  const evtPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'application', 'events', `${evtClassName}.java`);
@@ -566,10 +566,10 @@ async function updateDomainEventHandler(projectDir, packagePath, context) {
566
566
  return false;
567
567
  }
568
568
 
569
- // Compute domain event name by stripping 'Event' suffix from eventClassName
570
- // e.g. OrderPlacedEvent → OrderPlaced
571
- const domainEventName = context.eventClassName.endsWith('Event')
572
- ? context.eventClassName.slice(0, -'Event'.length)
569
+ // Compute domain event name by stripping 'IntegrationEvent' suffix from eventClassName
570
+ // e.g. OrderPlacedIntegrationEvent → OrderPlaced
571
+ const domainEventName = context.eventClassName.endsWith('IntegrationEvent')
572
+ ? context.eventClassName.slice(0, -'IntegrationEvent'.length)
573
573
  : context.eventClassName;
574
574
 
575
575
  // Guard: the TODO comment must exist (generated by g entities)
@@ -639,4 +639,58 @@ function injectImportIntoFile(content, importStatement) {
639
639
  }
640
640
  }
641
641
 
642
+ /**
643
+ * Returns the name of the installed message broker ('kafka' | 'rabbitmq') or null.
644
+ * Extensibility: add 'rabbitmq' support here when `eva add rabbitmq-client` is implemented.
645
+ * @param {import('../utils/config-manager')} configManager
646
+ * @returns {Promise<'kafka'|'rabbitmq'|null>}
647
+ */
648
+ async function getInstalledBroker(configManager) {
649
+ if (await configManager.featureExists('kafka')) return 'kafka';
650
+ if (await configManager.featureExists('rabbitmq')) return 'rabbitmq';
651
+ return null;
652
+ }
653
+
654
+ /**
655
+ * Build a Kafka event generation context for a given domain event.
656
+ * Intended for use by `generate-entities.js` to auto-wire broker integration events
657
+ * without requiring a separate `eva g kafka-event` invocation.
658
+ *
659
+ * @param {string} packageName
660
+ * @param {string} moduleName
661
+ * @param {{ name: string, fields: Array }} domainEvent - Domain event from parsed domain.yaml
662
+ * @param {{ partitions?: number, replicas?: number }} [options]
663
+ * @returns {Object} context ready for generateSingleKafkaEvent()
664
+ */
665
+ function buildKafkaEventContext(packageName, moduleName, domainEvent, { partitions = 3, replicas = 1 } = {}) {
666
+ const normalizedName = toPascalCase(domainEvent.name);
667
+ const integrationEventClassName = normalizedName.endsWith('IntegrationEvent')
668
+ ? normalizedName
669
+ : `${normalizedName}IntegrationEvent`;
670
+ const topicNameKebab = toKebabCase(domainEvent.name);
671
+ const topicNameCamel = toCamelCase(domainEvent.name);
672
+ const topicNameSnake = toSnakeCase(domainEvent.name).toUpperCase();
673
+ const topicSpringProperty = `\${topics.${topicNameKebab}}`;
674
+ return {
675
+ packageName,
676
+ moduleName,
677
+ modulePascalCase: toPascalCase(moduleName),
678
+ moduleCamelCase: toCamelCase(moduleName),
679
+ kafkaMessageBrokerClassName: `${toPascalCase(moduleName)}KafkaMessageBroker`,
680
+ eventClassName: integrationEventClassName,
681
+ topicNameSnake,
682
+ topicNameKebab,
683
+ topicNameCamel,
684
+ topicPropertyKey: topicNameKebab,
685
+ topicPropertyValue: topicNameSnake,
686
+ topicSpringProperty,
687
+ partitions,
688
+ replicas,
689
+ eventFields: domainEvent.fields || null
690
+ };
691
+ }
692
+
642
693
  module.exports = generateKafkaEventCommand;
694
+ module.exports.generateSingleKafkaEvent = generateSingleKafkaEvent;
695
+ module.exports.buildKafkaEventContext = buildKafkaEventContext;
696
+ module.exports.getInstalledBroker = getInstalledBroker;
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const path = require('path');
5
+ const fs = require('fs-extra');
6
+ const yaml = require('js-yaml');
7
+ const pluralize = require('pluralize');
8
+
9
+ const ConfigManager = require('../utils/config-manager');
10
+ const { isEva4jProject } = require('../utils/validator');
11
+ const { toCamelCase, toPascalCase, toPackagePath } = require('../utils/naming');
12
+
13
+ const addModuleCommand = require('./add-module');
14
+ const addKafkaClientCommand = require('./add-kafka-client');
15
+
16
+ // Supported brokers → add-client command mapping
17
+ const BROKER_CLIENT_COMMANDS = {
18
+ kafka: addKafkaClientCommand,
19
+ };
20
+
21
+ async function generateSystemCommand() {
22
+ const projectDir = process.cwd();
23
+
24
+ if (!(await isEva4jProject(projectDir))) {
25
+ console.error(chalk.red('❌ Not in an eva4j project directory'));
26
+ console.error(chalk.gray('Run this command inside a project created with eva4j'));
27
+ process.exit(1);
28
+ }
29
+
30
+ // ── Read system.yaml ──────────────────────────────────────────────────────
31
+ const systemYamlPath = path.join(projectDir, 'system.yaml');
32
+ if (!(await fs.pathExists(systemYamlPath))) {
33
+ console.error(chalk.red('❌ system.yaml not found in project root'));
34
+ console.error(chalk.gray('Create a system.yaml file first'));
35
+ process.exit(1);
36
+ }
37
+
38
+ let systemConfig;
39
+ try {
40
+ const content = await fs.readFile(systemYamlPath, 'utf-8');
41
+ systemConfig = yaml.load(content);
42
+ } catch (err) {
43
+ console.error(chalk.red('❌ Failed to parse system.yaml:'), err.message);
44
+ process.exit(1);
45
+ }
46
+
47
+ const { messaging, modules = [] } = systemConfig;
48
+
49
+ if (!modules.length) {
50
+ console.log(chalk.yellow('⚠️ No modules defined in system.yaml'));
51
+ process.exit(0);
52
+ }
53
+
54
+ console.log(chalk.blue('\n🚀 eva generate system\n'));
55
+
56
+ const configManager = new ConfigManager(projectDir);
57
+
58
+ // ── Step 1: Add modules ───────────────────────────────────────────────────
59
+ console.log(chalk.blue('\n📦 Adding modules...\n'));
60
+
61
+ for (const mod of modules) {
62
+ const modulePackageName = toCamelCase(mod.name);
63
+ const alreadyExists = await configManager.moduleExists(modulePackageName);
64
+
65
+ if (alreadyExists) {
66
+ console.log(chalk.gray(` ✓ Module '${mod.name}' already exists, skipping`));
67
+ } else {
68
+ await addModuleCommand(mod.name, {});
69
+ }
70
+ }
71
+
72
+ // ── Step 2: Messaging broker client ──────────────────────────────────────
73
+ if (messaging && messaging.enabled === true) {
74
+ const broker = messaging.broker;
75
+ const addClientFn = BROKER_CLIENT_COMMANDS[broker];
76
+
77
+ if (!addClientFn) {
78
+ console.log(chalk.yellow(` ⚠️ Broker '${broker}' is not yet supported. Skipping client setup.`));
79
+ } else {
80
+ const alreadyInstalled = await configManager.featureExists(broker);
81
+ if (alreadyInstalled) {
82
+ console.log(chalk.gray(` ✓ ${broker}-client already installed, skipping\n`));
83
+ } else {
84
+ console.log(chalk.blue(`\n📡 Adding ${broker}-client...\n`));
85
+ await addClientFn();
86
+ }
87
+ }
88
+ }
89
+
90
+ // ── Step 3: Generate domain.yaml per module ───────────────────────────────
91
+ const projectConfig = await configManager.loadProjectConfig();
92
+ if (!projectConfig) {
93
+ console.error(chalk.red('❌ Could not load project configuration'));
94
+ process.exit(1);
95
+ }
96
+
97
+ const packagePath = toPackagePath(projectConfig.packageName);
98
+
99
+ console.log(chalk.blue('\n📄 Generating domain.yaml skeletons...\n'));
100
+
101
+ for (const mod of modules) {
102
+ const modulePackageName = toCamelCase(mod.name);
103
+ const moduleDir = path.join(projectDir, 'src', 'main', 'java', packagePath, modulePackageName);
104
+ const domainYamlPath = path.join(moduleDir, 'domain.yaml');
105
+
106
+ const existed = await fs.pathExists(domainYamlPath);
107
+ const content = buildDomainYaml(mod, systemConfig);
108
+ await fs.writeFile(domainYamlPath, content, 'utf-8');
109
+
110
+ if (existed) {
111
+ console.log(chalk.yellow(` ♻️ ${modulePackageName}/domain.yaml overwritten`));
112
+ } else {
113
+ console.log(chalk.green(` ✨ ${modulePackageName}/domain.yaml created`));
114
+ }
115
+ }
116
+
117
+ console.log(chalk.blue('\n✅ System bootstrap complete!\n'));
118
+ console.log(chalk.white('Next steps:'));
119
+ console.log(chalk.gray(" 1. Edit each module's domain.yaml — add fields, enums, and refine the aggregate"));
120
+ console.log(chalk.gray(' 2. Run: eva g entities <module> (for each module)'));
121
+ console.log(chalk.gray('\n Tip: run eva system validate to check cross-module consistency'));
122
+ console.log();
123
+ }
124
+
125
+ // ── Domain YAML builder ───────────────────────────────────────────────────────
126
+
127
+ function buildDomainYaml(mod, systemConfig) {
128
+ const integrations = systemConfig.integrations || {};
129
+ const asyncEvents = integrations.async || [];
130
+ const syncCalls = integrations.sync || [];
131
+
132
+ const moduleName = mod.name;
133
+ const aggregateName = toPascalCase(pluralize.singular(moduleName));
134
+ const entityName = aggregateName.charAt(0).toLowerCase() + aggregateName.slice(1);
135
+ const tableName = moduleName.replace(/-/g, '_');
136
+
137
+ // Events this module produces
138
+ const producedEvents = asyncEvents.filter(e => e.producer === moduleName);
139
+ // Sync calls this module makes as caller
140
+ const outboundPorts = syncCalls.filter(s => s.caller === moduleName);
141
+ // REST endpoints exposed by this module
142
+ const exposes = mod.exposes || [];
143
+
144
+ const lines = [];
145
+ const today = new Date().toISOString().split('T')[0];
146
+
147
+ lines.push(`# domain.yaml — ${moduleName}`);
148
+ lines.push(`# Generated by: eva generate system (${today})`);
149
+ lines.push(`#`);
150
+ lines.push(`# TODO: Complete this file:`);
151
+ lines.push(`# - Add entity fields under aggregates[].entities[].fields`);
152
+ if (producedEvents.length) lines.push(`# - Add event fields under aggregates[].events[].fields`);
153
+ if (outboundPorts.length) lines.push(`# - Add response shapes to ports[].methods[].response`);
154
+ if (exposes.length) lines.push(`# - Verify endpoints[].operations match the use cases in aggregates[]`);
155
+ lines.push(``);
156
+
157
+ // ── aggregates ────────────────────────────────────────────────────────────
158
+ lines.push(`aggregates:`);
159
+ lines.push(` - name: ${aggregateName}`);
160
+ lines.push(` entities:`);
161
+ lines.push(` - name: ${entityName}`);
162
+ lines.push(` isRoot: true`);
163
+ lines.push(` tableName: ${tableName}`);
164
+ lines.push(` audit:`);
165
+ lines.push(` enabled: true`);
166
+ lines.push(` fields:`);
167
+ lines.push(` - name: id`);
168
+ lines.push(` type: String`);
169
+ lines.push(` # TODO: add more fields`);
170
+
171
+ if (producedEvents.length) {
172
+ lines.push(` events:`);
173
+ for (const ev of producedEvents) {
174
+ lines.push(` - name: ${ev.event}`);
175
+ lines.push(` fields: [] # TODO: add event fields`);
176
+ lines.push(` kafka: true`);
177
+ }
178
+ }
179
+
180
+ lines.push(``);
181
+
182
+ // ── endpoints ─────────────────────────────────────────────────────────────
183
+ if (exposes.length) {
184
+ // Derive basePath from the first exposed endpoint (e.g. /orders/{id} → /orders)
185
+ const basePath = '/' + (exposes[0].path || '').replace(/^\//, '').split('/')[0];
186
+ lines.push(`endpoints:`);
187
+ lines.push(` basePath: ${basePath}`);
188
+ lines.push(` versions:`);
189
+ lines.push(` - version: v1`);
190
+ lines.push(` operations:`);
191
+ for (const ep of exposes) {
192
+ lines.push(` - useCase: ${ep.useCase}`);
193
+ lines.push(` method: ${ep.method}`);
194
+ lines.push(` path: ${ep.path}`);
195
+ if (ep.description) lines.push(` description: "${ep.description}"`);
196
+ }
197
+ lines.push(``);
198
+ }
199
+
200
+ // ── ports ─────────────────────────────────────────────────────────────────
201
+ if (outboundPorts.length) {
202
+ lines.push(`ports:`);
203
+ for (const port of outboundPorts) {
204
+ lines.push(` - name: ${port.port}`);
205
+ lines.push(` target: ${port.calls} # from system.yaml integrations.sync`);
206
+ lines.push(` methods:`);
207
+ for (const endpoint of (port.using || [])) {
208
+ const methodName = deriveMethodName(endpoint);
209
+ lines.push(` - name: ${methodName} # TODO: rename if needed`);
210
+ lines.push(` http: ${endpoint}`);
211
+ lines.push(` response: [] # TODO: add response fields`);
212
+ }
213
+ }
214
+ lines.push(``);
215
+ }
216
+
217
+ return lines.join('\n');
218
+ }
219
+
220
+ /**
221
+ * Derive a camelCase Java method name from an HTTP entry like "GET /customers/{id}"
222
+ */
223
+ function deriveMethodName(httpEntry) {
224
+ const parts = httpEntry.trim().split(/\s+/);
225
+ const method = (parts[0] || 'GET').toUpperCase();
226
+ const urlPath = parts[1] || '/';
227
+
228
+ const segments = urlPath.split('/').filter(s => s.length > 0);
229
+ const hasId = segments.some(s => s.charAt(0) === '{');
230
+ const resourceSegments = segments.filter(s => s.charAt(0) !== '{');
231
+ const lastResource = resourceSegments[resourceSegments.length - 1] || 'resource';
232
+ const singular = pluralize.singular(lastResource);
233
+ const pascal = toPascalCase(singular);
234
+
235
+ if (method === 'GET' && hasId) return `find${pascal}ById`;
236
+ if (method === 'GET') return `findAll${toPascalCase(lastResource)}`;
237
+ if (method === 'POST') return `create${pascal}`;
238
+ if (method === 'PUT' || method === 'PATCH') return `update${pascal}`;
239
+ if (method === 'DELETE') return `delete${pascal}`;
240
+ return toCamelCase(`${method.toLowerCase()}_${lastResource}`);
241
+ }
242
+
243
+ module.exports = generateSystemCommand;
@@ -8,7 +8,7 @@ class BaseGenerator {
8
8
  constructor(context) {
9
9
  this.context = context;
10
10
  this.templatesDir = path.join(__dirname, '../../templates/base');
11
- this.projectDir = path.join(process.cwd(), context.artifactId);
11
+ this.projectDir = path.join(process.cwd(), context.projectName);
12
12
  }
13
13
 
14
14
  async generate() {
@@ -130,6 +130,14 @@ class BaseGenerator {
130
130
  path.join(this.projectDir, 'README.md'));
131
131
  await this.generateFile('root/AGENTS.md.ejs',
132
132
  path.join(this.projectDir, 'AGENTS.md'));
133
+ await this.generateFile('root/system.yaml.ejs',
134
+ path.join(this.projectDir, 'system.yaml'));
135
+ await this.generateFile('root/skill-build-system-yaml.ejs',
136
+ path.join(this.projectDir, '.agents', 'skills', 'build-system-yaml', 'SKILL.md'));
137
+ await this.generateFile('root/skill-build-domain-yaml.ejs',
138
+ path.join(this.projectDir, '.agents', 'skills', 'build-domain-yaml', 'SKILL.md'));
139
+ await this.generateFile('root/skill-build-domain-yaml-references-generate-entities.md.ejs',
140
+ path.join(this.projectDir, '.agents', 'skills', 'build-domain-yaml', 'references', 'GENERATE_ENTITIES.md'));
133
141
 
134
142
  if (this.context.features.includeDocker) {
135
143
  await this.generateFile('docker/docker-compose.yaml.ejs',
@@ -84,12 +84,13 @@ function getBaseEntity(hasSoftDelete, hasAudit) {
84
84
  /**
85
85
  * Convert artifact ID to valid Java package name
86
86
  * @param {string} artifactId - Artifact ID (e.g., my-project)
87
- * @returns {string} Valid package name (e.g., myproject)
87
+ * @returns {string} Valid package name (e.g., my_project)
88
88
  */
89
89
  function artifactIdToPackageName(artifactId) {
90
90
  return artifactId
91
91
  .toLowerCase()
92
- .replace(/[^a-z0-9]/g, '');
92
+ .replace(/-/g, '_')
93
+ .replace(/[^a-z0-9_]/g, '');
93
94
  }
94
95
 
95
96
  /**
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Validates a parsed system.yaml object against 5 architectural checks.
5
+ *
6
+ * @param {object} systemConfig - Parsed system.yaml content
7
+ * @returns {{ errors: string[], warnings: string[], ok: string[], score: number }}
8
+ */
9
+ function validateSystem(systemConfig) {
10
+ const errors = [];
11
+ const warnings = [];
12
+ const ok = [];
13
+
14
+ const modules = systemConfig.modules || [];
15
+ const moduleNames = new Set(modules.map((m) => m.name));
16
+ const integrations = systemConfig.integrations || {};
17
+ const asyncEvents = integrations.async || [];
18
+ const syncIntegrations = integrations.sync || [];
19
+
20
+ // Build a quick lookup: module name → exposes array of "METHOD /path" strings
21
+ const moduleExposes = {};
22
+ for (const mod of modules) {
23
+ moduleExposes[mod.name] = (mod.exposes || []).map(
24
+ (ep) => `${ep.method} ${ep.path}`
25
+ );
26
+ }
27
+
28
+ // ── Check 1: Referential Integrity ────────────────────────────────────────
29
+
30
+ // 1a. Event producers
31
+ 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
+ );
36
+ } else {
37
+ ok.push(`Productor '${ev.producer}' del evento '${ev.event}' existe ✓`);
38
+ }
39
+ }
40
+
41
+ // 1b. Event consumers
42
+ 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
+ );
50
+ }
51
+ }
52
+ }
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 ✓');
55
+ }
56
+
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
+ );
63
+ }
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[]`
67
+ );
68
+ }
69
+ }
70
+
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
+ }
88
+ }
89
+ }
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 ✓');
92
+ }
93
+
94
+ // ── Check 2: Cycle Detection (sync deps) ─────────────────────────────────
95
+
96
+ // Build directed graph: A → B when caller=A, calls=B
97
+ const syncGraph = {};
98
+ for (const sync of syncIntegrations) {
99
+ if (!syncGraph[sync.caller]) syncGraph[sync.caller] = [];
100
+ syncGraph[sync.caller].push(sync.calls);
101
+ }
102
+
103
+ // Detect strict bidirectional sync coupling (A→B and B→A)
104
+ const biDirChecked = new Set();
105
+ let biDirFound = false;
106
+ for (const sync of syncIntegrations) {
107
+ const key = [sync.caller, sync.calls].sort().join('↔');
108
+ if (biDirChecked.has(key)) continue;
109
+ biDirChecked.add(key);
110
+ const reverse = syncIntegrations.find(
111
+ (s) => s.caller === sync.calls && s.calls === sync.caller
112
+ );
113
+ 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;
118
+ }
119
+ }
120
+
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;
133
+ }
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;
147
+ }
148
+ }
149
+
150
+ if (!biDirFound && !cycleFound) {
151
+ ok.push('No se detectaron ciclos ni acoplamiento síncrono bidireccional ✓');
152
+ }
153
+
154
+ // ── Check 3: Role Analysis ────────────────────────────────────────────────
155
+
156
+ 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;
166
+
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)`
174
+ );
175
+ } else if (!hasAnyIntegration) {
176
+ ok.push(`'${mod.name}' es un módulo autónomo sin dependencias de integración`);
177
+ }
178
+
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)`);
182
+ }
183
+ }
184
+
185
+ // ── Check 4: Behavior Gaps ─────────────────────────────────────────────────
186
+
187
+ const schedulerVerbs = ['expire', 'clean', 'close', 'archive', 'timeout', 'process', 'purge', 'flush'];
188
+ const mutationMethods = new Set(['PUT', 'PATCH', 'DELETE', 'POST']);
189
+
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
+ })
209
+ );
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
+ );
218
+ }
219
+ }
220
+ }
221
+
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
+ }
228
+
229
+ // ── Check 5: Coupling Patterns ────────────────────────────────────────────
230
+
231
+ for (const sync of syncIntegrations) {
232
+ const caller = sync.caller;
233
+ const callee = sync.calls;
234
+
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);
239
+ });
240
+
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
+ );
246
+ }
247
+ }
248
+
249
+ // Detect dual trigger: endpoint appears in both sync.using and as event-triggered consumer
250
+ 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
274
+ }
275
+ }
276
+
277
+ // Highlight producer-only modules (no event consumption, no sync calls received) — healthy pattern
278
+ 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) ✓`);
288
+ }
289
+ }
290
+
291
+ // ── Score ────────────────────────────────────────────────────────────────
292
+
293
+ const total = ok.length + errors.length + warnings.length * 0.5;
294
+ const score = total > 0 ? Math.round((ok.length / total) * 100) : 100;
295
+
296
+ return { errors, warnings, ok, score };
297
+ }
298
+
299
+ /**
300
+ * Compares two HTTP endpoint strings with path param normalization.
301
+ * e.g. "GET /screenings/{id}/seats" matches "GET /screenings/{id}/seats"
302
+ * Path params ({anything}) are treated as wildcards.
303
+ */
304
+ function endpointMatches(declared, used) {
305
+ const normalizePath = (str) =>
306
+ str
307
+ .trim()
308
+ .toUpperCase()
309
+ .replace(/\{[^}]+\}/g, '{*}')
310
+ .replace(/\/+$/, '');
311
+ return normalizePath(declared) === normalizePath(used);
312
+ }
313
+
314
+ module.exports = { validateSystem };