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.
- package/AGENTS.md +51 -9
- package/DOMAIN_YAML_GUIDE.md +150 -0
- package/bin/eva4j.js +31 -1
- package/design-system.md +797 -0
- package/docs/commands/EVALUATE_SYSTEM.md +542 -0
- package/docs/commands/GENERATE_ENTITIES.md +196 -0
- 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/system.yaml +289 -0
- package/package.json +1 -1
- package/src/commands/create.js +6 -3
- package/src/commands/evaluate-system.js +384 -0
- package/src/commands/generate-entities.js +677 -14
- package/src/commands/generate-kafka-event.js +59 -5
- package/src/commands/generate-system.js +243 -0
- package/src/generators/base-generator.js +9 -1
- package/src/utils/naming.js +3 -2
- package/src/utils/system-validator.js +314 -0
- package/src/utils/yaml-to-entity.js +31 -2
- package/templates/aggregate/AggregateRepository.java.ejs +5 -0
- package/templates/aggregate/AggregateRepositoryImpl.java.ejs +9 -0
- package/templates/aggregate/DomainEventHandler.java.ejs +24 -20
- package/templates/aggregate/JpaRepository.java.ejs +5 -0
- package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1103 -0
- package/templates/base/root/skill-build-domain-yaml.ejs +292 -0
- package/templates/base/root/skill-build-system-yaml.ejs +252 -0
- package/templates/base/root/system.yaml.ejs +97 -0
- 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/ScaffoldCommand.java.ejs +12 -0
- package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
- package/templates/crud/ScaffoldQuery.java.ejs +12 -0
- package/templates/crud/ScaffoldQueryHandler.java.ejs +40 -0
- package/templates/crud/SubEntityAddCommand.java.ejs +17 -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/evaluate/report.html.ejs +971 -0
- 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('
|
|
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 '
|
|
570
|
-
// e.g.
|
|
571
|
-
const domainEventName = context.eventClassName.endsWith('
|
|
572
|
-
? context.eventClassName.slice(0, -'
|
|
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.
|
|
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',
|
package/src/utils/naming.js
CHANGED
|
@@ -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.,
|
|
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(
|
|
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 };
|