eva4j 1.0.17 → 1.0.18

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 (134) hide show
  1. package/AGENTS.md +2 -0
  2. package/DOMAIN_YAML_GUIDE.md +3 -1
  3. package/QUICK_REFERENCE.md +8 -4
  4. package/bin/eva4j.js +70 -2
  5. package/config/defaults.json +1 -0
  6. package/docs/CAMUNDA_DMN_GUIDE.md +1380 -0
  7. package/docs/KAFKA_PRODUCTION_CONFIG.md +441 -0
  8. package/docs/RABBITMQ_PRODUCTION_CONFIG.md +227 -0
  9. package/docs/commands/ADD_RABBITMQ_CLIENT.md +192 -0
  10. package/docs/commands/EVALUATE_SYSTEM.md +272 -8
  11. package/docs/commands/GENERATE_RABBITMQ_EVENT.md +341 -0
  12. package/docs/commands/GENERATE_RABBITMQ_LISTENER.md +595 -0
  13. package/docs/commands/GENERATE_TEMPORAL_FLOW.md +52 -12
  14. package/docs/commands/INDEX.md +27 -3
  15. package/docs/prototype/TEMPORAL_COMMUNICATION_PATTERNS.md +731 -0
  16. package/docs/prototype/TEMPORAL_DESIGN_METHODOLOGY.md +740 -0
  17. package/docs/prototype/system/RISKS.md +277 -0
  18. package/docs/prototype/system/customers.yaml +133 -0
  19. package/docs/prototype/system/inventory.yaml +109 -0
  20. package/docs/prototype/system/notifications.yaml +131 -0
  21. package/docs/prototype/system/orders.yaml +241 -0
  22. package/docs/prototype/system/payments.yaml +256 -0
  23. package/docs/prototype/system/products.yaml +168 -0
  24. package/docs/prototype/system/system.yaml +269 -0
  25. package/examples/domain-endpoints-multi-aggregate.yaml +140 -0
  26. package/examples/domain-read-models.yaml +2 -2
  27. package/examples/system/customer.yaml +89 -0
  28. package/examples/system/orders.yaml +119 -0
  29. package/examples/system/product.yaml +27 -0
  30. package/examples/system/system.yaml +80 -0
  31. package/package.json +1 -1
  32. package/src/agents/design-gap-analyst-temporal.agent.md +452 -0
  33. package/src/agents/design-gap-analyst.agent.md +383 -0
  34. package/src/agents/design-reviewer-temporal.agent.md +412 -0
  35. package/src/agents/design-reviewer.agent.md +31 -5
  36. package/src/agents/implement-use-cases.prompt.md +179 -0
  37. package/src/agents/ux-gap-analyst.agent.md +412 -0
  38. package/src/commands/add-rabbitmq-client.js +261 -0
  39. package/src/commands/add-temporal-client.js +22 -2
  40. package/src/commands/build.js +267 -11
  41. package/src/commands/evaluate-system.js +700 -13
  42. package/src/commands/generate-entities.js +308 -16
  43. package/src/commands/generate-rabbitmq-event.js +665 -0
  44. package/src/commands/generate-rabbitmq-listener.js +205 -0
  45. package/src/commands/generate-temporal-activity.js +968 -34
  46. package/src/commands/generate-temporal-flow.js +95 -38
  47. package/src/commands/generate-temporal-system.js +708 -0
  48. package/src/skills/build-system-yaml/SKILL.md +222 -2
  49. package/src/skills/build-system-yaml/references/domain-yaml-spec.md +50 -4
  50. package/src/skills/build-system-yaml/references/module-spec.md +57 -8
  51. package/src/skills/build-temporal-system/SKILL.md +752 -0
  52. package/src/skills/build-temporal-system/references/temporal-communication-patterns.md +167 -0
  53. package/src/skills/build-temporal-system/references/temporal-domain-yaml-spec.md +449 -0
  54. package/src/skills/build-temporal-system/references/temporal-module-spec.md +353 -0
  55. package/src/skills/build-temporal-system/references/temporal-system-yaml-spec.md +326 -0
  56. package/src/skills/implement-use-case/SKILL.md +350 -0
  57. package/src/skills/implement-use-case/references/use-case-patterns.md +980 -0
  58. package/src/skills/requirements-elicitation/SKILL.md +228 -0
  59. package/src/skills/requirements-elicitation/references/interview-framework.md +260 -0
  60. package/src/skills/requirements-elicitation/references/output-templates.md +368 -0
  61. package/src/utils/bounded-context-diagram.js +844 -0
  62. package/src/utils/domain-validator.js +266 -4
  63. package/src/utils/naming.js +10 -0
  64. package/src/utils/system-validator.js +169 -11
  65. package/src/utils/system-yaml-parser.js +318 -0
  66. package/src/utils/temporal-validator.js +497 -0
  67. package/src/utils/yaml-to-entity.js +10 -7
  68. package/templates/aggregate/DomainEventHandler.java.ejs +116 -22
  69. package/templates/aggregate/JpaAggregateRoot.java.ejs +2 -2
  70. package/templates/aggregate/JpaEntity.java.ejs +2 -2
  71. package/templates/base/docker/rabbitmq-services.yaml.ejs +12 -0
  72. package/templates/base/resources/parameters/develop/kafka.yaml.ejs +5 -0
  73. package/templates/base/resources/parameters/develop/rabbitmq.yaml.ejs +15 -0
  74. package/templates/base/resources/parameters/develop/temporal.yaml.ejs +0 -3
  75. package/templates/base/resources/parameters/local/kafka.yaml.ejs +5 -0
  76. package/templates/base/resources/parameters/local/rabbitmq.yaml.ejs +15 -0
  77. package/templates/base/resources/parameters/local/temporal.yaml.ejs +0 -3
  78. package/templates/base/resources/parameters/production/kafka.yaml.ejs +39 -8
  79. package/templates/base/resources/parameters/production/rabbitmq.yaml.ejs +32 -0
  80. package/templates/base/resources/parameters/production/temporal.yaml.ejs +0 -3
  81. package/templates/base/resources/parameters/test/kafka.yaml.ejs +12 -6
  82. package/templates/base/resources/parameters/test/rabbitmq.yaml.ejs +15 -0
  83. package/templates/base/resources/parameters/test/temporal.yaml.ejs +0 -3
  84. package/templates/base/root/AGENTS.md.ejs +1 -1
  85. package/templates/crud/EndpointsController.java.ejs +1 -1
  86. package/templates/crud/ScaffoldCommand.java.ejs +5 -2
  87. package/templates/crud/ScaffoldCommandHandler.java.ejs +3 -1
  88. package/templates/crud/ScaffoldQuery.java.ejs +5 -2
  89. package/templates/crud/ScaffoldQueryHandler.java.ejs +3 -1
  90. package/templates/crud/SubEntityRemoveCommand.java.ejs +1 -1
  91. package/templates/evaluate/report.html.ejs +1447 -90
  92. package/templates/kafka-event/KafkaConfigBean.java.ejs +1 -1
  93. package/templates/kafka-event/KafkaMessageBroker.java.ejs +3 -3
  94. package/templates/ports/PortAclMapper.java.ejs +35 -0
  95. package/templates/ports/PortFeignAdapter.java.ejs +7 -22
  96. package/templates/ports/PortFeignClient.java.ejs +4 -0
  97. package/templates/ports/PortResponseDto.java.ejs +1 -1
  98. package/templates/rabbitmq-event/RabbitConfigBean.java.ejs +33 -0
  99. package/templates/rabbitmq-event/RabbitConfigExchange.java.ejs +12 -0
  100. package/templates/rabbitmq-event/RabbitMessageBroker.java.ejs +35 -0
  101. package/templates/rabbitmq-event/RabbitMessageBrokerMethod.java.ejs +9 -0
  102. package/templates/rabbitmq-listener/RabbitConfigConsumerBean.java.ejs +33 -0
  103. package/templates/rabbitmq-listener/RabbitConfigConsumerExchange.java.ejs +12 -0
  104. package/templates/rabbitmq-listener/RabbitListenerClass.java.ejs +82 -0
  105. package/templates/rabbitmq-listener/RabbitListenerSimple.java.ejs +56 -0
  106. package/templates/read-model/ReadModelJpa.java.ejs +1 -1
  107. package/templates/read-model/ReadModelJpaRepository.java.ejs +2 -0
  108. package/templates/read-model/ReadModelRabbitListener.java.ejs +71 -0
  109. package/templates/read-model/ReadModelRepositoryImpl.java.ejs +9 -5
  110. package/templates/read-model/ReadModelSyncHandler.java.ejs +2 -0
  111. package/templates/shared/configurations/kafkaConfig/KafkaConfig.java.ejs +18 -4
  112. package/templates/shared/configurations/rabbitmqConfig/RabbitMQConfig.java.ejs +100 -0
  113. package/templates/shared/configurations/temporalConfig/TemporalConfig.java.ejs +2 -64
  114. package/templates/shared/configurations/temporalConfig/TemporalWorkerFactoryLifecycle.java.ejs +41 -0
  115. package/templates/temporal-activity/ActivityImpl.java.ejs +68 -2
  116. package/templates/temporal-activity/ActivityInput.java.ejs +14 -0
  117. package/templates/temporal-activity/ActivityInterface.java.ejs +7 -1
  118. package/templates/temporal-activity/ActivityOutput.java.ejs +14 -0
  119. package/templates/temporal-activity/NestedType.java.ejs +12 -0
  120. package/templates/temporal-activity/SharedActivityInput.java.ejs +14 -0
  121. package/templates/temporal-activity/SharedActivityInterface.java.ejs +15 -0
  122. package/templates/temporal-activity/SharedActivityOutput.java.ejs +14 -0
  123. package/templates/temporal-activity/SharedNestedType.java.ejs +12 -0
  124. package/templates/temporal-flow/ModuleHeavyActivity.java.ejs +6 -0
  125. package/templates/temporal-flow/ModuleLightActivity.java.ejs +6 -0
  126. package/templates/temporal-flow/ModuleTemporalWorkerConfig.java.ejs +58 -0
  127. package/templates/temporal-flow/WorkFlowImpl.java.ejs +172 -12
  128. package/templates/temporal-flow/WorkFlowInput.java.ejs +11 -0
  129. package/templates/temporal-flow/WorkFlowInterface.java.ejs +5 -4
  130. package/templates/temporal-flow/WorkFlowService.java.ejs +42 -12
  131. package/COMMAND_EVALUATION.md +0 -911
  132. package/test-c2010.js +0 -49
  133. package/test-update-compat.js +0 -109
  134. package/test-update-lifecycle.js +0 -121
@@ -3,12 +3,499 @@ const ora = require('ora');
3
3
  const chalk = require('chalk');
4
4
  const path = require('path');
5
5
  const fs = require('fs-extra');
6
+ const yaml = require('js-yaml');
6
7
  const ConfigManager = require('../utils/config-manager');
7
8
  const { isEva4jProject, moduleExists } = require('../utils/validator');
8
- const { toPackagePath, toPascalCase, toCamelCase } = require('../utils/naming');
9
+ const { toPackagePath, toPascalCase, toCamelCase, toScreamingSnakeCase } = require('../utils/naming');
9
10
  const { renderAndWrite } = require('../utils/template-engine');
10
11
  const ChecksumManager = require('../utils/checksum-manager');
11
12
 
13
+ // ─── Java type → import mapping ─────────────────────────────────────────────
14
+ const JAVA_TYPE_IMPORTS = {
15
+ BigDecimal: 'java.math.BigDecimal',
16
+ LocalDateTime: 'java.time.LocalDateTime',
17
+ LocalDate: 'java.time.LocalDate',
18
+ LocalTime: 'java.time.LocalTime',
19
+ Instant: 'java.time.Instant',
20
+ UUID: 'java.util.UUID',
21
+ List: 'java.util.List',
22
+ };
23
+
24
+ /**
25
+ * Resolve Java imports for a list of typed fields.
26
+ * @param {Array<{javaType: string}>} fields
27
+ * @returns {string[]} sorted import statements
28
+ */
29
+ function resolveFieldImports(fields) {
30
+ const imports = new Set();
31
+ for (const field of fields) {
32
+ for (const [type, imp] of Object.entries(JAVA_TYPE_IMPORTS)) {
33
+ if (field.javaType && field.javaType.includes(type)) {
34
+ imports.add(`import ${imp};`);
35
+ }
36
+ }
37
+ }
38
+ return Array.from(imports).sort();
39
+ }
40
+
41
+ /**
42
+ * Parse the `activities:` section from a module's domain.yaml.
43
+ * Returns null if the file does not exist or has no activities.
44
+ * @param {string} domainYamlPath
45
+ * @returns {Array|null}
46
+ */
47
+ async function parseActivitiesFromYaml(domainYamlPath) {
48
+ if (!(await fs.pathExists(domainYamlPath))) return null;
49
+ const content = await fs.readFile(domainYamlPath, 'utf-8');
50
+ const data = yaml.load(content);
51
+ if (!data || !Array.isArray(data.activities) || data.activities.length === 0) return null;
52
+ return data.activities;
53
+ }
54
+
55
+ /**
56
+ * Extract aggregate info (root entity name + fields) from domain.yaml data.
57
+ * Returns null if no aggregate with a root entity is found.
58
+ * @param {string} domainYamlPath
59
+ * @returns {object|null} { aggregateName, rootEntityName, rootFieldNames: Set<string>, idFieldName, idFieldType }
60
+ */
61
+ async function parseAggregateInfo(domainYamlPath) {
62
+ if (!(await fs.pathExists(domainYamlPath))) return null;
63
+ const content = await fs.readFile(domainYamlPath, 'utf-8');
64
+ const data = yaml.load(content);
65
+ if (!data || !Array.isArray(data.aggregates) || data.aggregates.length === 0) return null;
66
+
67
+ for (const agg of data.aggregates) {
68
+ const entities = Array.isArray(agg.entities) ? agg.entities : [];
69
+ const root = entities.find((e) => e.isRoot === true);
70
+ if (!root || !Array.isArray(root.fields)) continue;
71
+
72
+ const aggregateName = toPascalCase(agg.name);
73
+ const rootEntityName = toPascalCase(root.name);
74
+ const rootFieldNames = new Set(root.fields.map((f) => f.name));
75
+ const rootFieldTypes = new Map(root.fields.map((f) => [f.name, f.type || 'String']));
76
+ const idField = root.fields.find((f) => f.name === 'id');
77
+
78
+ // Build voFieldsMap: PascalCase VO name → raw fields array
79
+ const voFieldsMap = new Map();
80
+ const voList = Array.isArray(agg.valueObjects) ? agg.valueObjects : [];
81
+ for (const vo of voList) {
82
+ voFieldsMap.set(toPascalCase(vo.name), Array.isArray(vo.fields) ? vo.fields : []);
83
+ }
84
+
85
+ // Build lazyRelationshipFields: camelCase field name → { relType, target, jpaFieldName }
86
+ // OneToMany / ManyToMany are LAZY by JPA default; OneToOne / ManyToOne are EAGER by default.
87
+ const lazyRelationshipFields = new Map();
88
+ const relationships = Array.isArray(root.relationships) ? root.relationships : [];
89
+ for (const rel of relationships) {
90
+ const relType = (rel.type || '').trim();
91
+ const fetchType = (rel.fetch || '').toUpperCase();
92
+ const isToMany = relType === 'OneToMany' || relType === 'ManyToMany';
93
+ // LAZY when: *ToMany (unless explicitly EAGER) OR any type explicitly marked LAZY
94
+ const isLazy = isToMany ? fetchType !== 'EAGER' : fetchType === 'LAZY';
95
+ if (!isLazy) continue;
96
+ const targetPascal = toPascalCase(rel.target || '');
97
+ if (!targetPascal) continue;
98
+ const fieldBase = targetPascal.charAt(0).toLowerCase() + targetPascal.slice(1);
99
+ const fieldName = isToMany ? fieldBase + 's' : fieldBase; // simple plural for *ToMany
100
+ lazyRelationshipFields.set(fieldName, { relType, target: targetPascal, jpaFieldName: fieldName });
101
+ }
102
+
103
+ return {
104
+ aggregateName,
105
+ rootEntityName,
106
+ rootFieldNames,
107
+ rootFieldTypes,
108
+ voFieldsMap,
109
+ lazyRelationshipFields,
110
+ idFieldName: idField ? idField.name : 'id',
111
+ idFieldType: idField ? (idField.type || 'String') : 'String',
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Check if a field name represents an entity ID for the given aggregate.
119
+ * Matches: 'id', '{entityName}Id' (e.g. 'shoppingCartId' for ShoppingCart),
120
+ * or the abbreviated last-word form: '{lastWord}Id' (e.g. 'cartId' for ShoppingCart).
121
+ * @param {string} fieldName
122
+ * @param {object} aggregateInfo
123
+ * @returns {boolean}
124
+ */
125
+ function isIdField(fieldName, aggregateInfo) {
126
+ const entityLower = aggregateInfo.rootEntityName.charAt(0).toLowerCase() + aggregateInfo.rootEntityName.slice(1);
127
+ // Split camelCase to get the last word: 'shoppingCart' → ['shopping','Cart'] → 'cart'
128
+ const nameParts = entityLower.split(/(?=[A-Z])/);
129
+ const lastWord = nameParts[nameParts.length - 1].toLowerCase();
130
+ return fieldName === 'id'
131
+ || fieldName === `${entityLower}Id`
132
+ || (nameParts.length > 1 && fieldName === `${lastWord}Id`);
133
+ }
134
+
135
+ /**
136
+ * Map a single YAML field definition {name, type} to {name, javaType}.
137
+ * @param {{name: string, type: string}} field
138
+ * @returns {{name: string, javaType: string}}
139
+ */
140
+ function mapField(field) {
141
+ return { name: field.name, javaType: field.type || 'String' };
142
+ }
143
+
144
+ /**
145
+ * Build the template context for a single activity from its YAML definition.
146
+ * @param {object} activity - Raw activity object from domain.yaml
147
+ * @param {string} packageName
148
+ * @param {string} moduleName
149
+ * @param {boolean} shared - Whether this activity's contracts live in shared/
150
+ * @param {object|null} aggregateInfo - Aggregate info for query activity detection
151
+ * @returns {object} context for templates
152
+ */
153
+ function buildActivityContext(activity, packageName, moduleName, shared = false, aggregateInfo = null, sharedNestedTypes = new Set(), existingSharedTypes = new Map()) {
154
+ const activityPascalCase = toPascalCase(activity.name);
155
+ const modulePascalCase = toPascalCase(moduleName);
156
+ const categoryType = (activity.type || 'light').toLowerCase() === 'heavy' ? 'HeavyActivity' : 'LightActivity';
157
+ const activityCategory = `${modulePascalCase}${categoryType}`;
158
+
159
+ const inputFields = Array.isArray(activity.input) ? activity.input.map(mapField) : [];
160
+ const outputFields = Array.isArray(activity.output) ? activity.output.map(mapField) : [];
161
+ const hasInput = inputFields.length > 0;
162
+ const hasOutput = outputFields.length > 0;
163
+
164
+ // ── Query activity detection ──────────────────────────────────────────────
165
+ // An activity is a "query activity" when:
166
+ // 1. Name starts with "Get"
167
+ // 2. Exactly 1 input field that is an entity ID
168
+ // 3. Has output fields
169
+ // 4. Module has an aggregate with root entity
170
+ let isQueryActivity = false;
171
+ let queryMeta = null;
172
+
173
+ if (aggregateInfo
174
+ && activityPascalCase.startsWith('Get')
175
+ && inputFields.length === 1
176
+ && hasOutput
177
+ && isIdField(inputFields[0].name, aggregateInfo)) {
178
+ isQueryActivity = true;
179
+ const idAccessor = `input.${inputFields[0].name}()`;
180
+ const entityLower = aggregateInfo.rootEntityName.charAt(0).toLowerCase() + aggregateInfo.rootEntityName.slice(1);
181
+ const entityIdAlias = `${entityLower}Id`; // e.g. 'customerId' for Customer
182
+ const SIMPLE_TYPES = new Set(['String', 'Integer', 'Long', 'Boolean', 'BigDecimal', 'LocalDate', 'LocalDateTime', 'LocalTime', 'Instant', 'UUID', 'Double', 'Float']);
183
+ // Build nestedTypesMap: PascalCase name → raw fields array (for VO→nestedType detection)
184
+ const nestedTypesMap = new Map();
185
+ if (Array.isArray(activity.nestedTypes)) {
186
+ for (const nt of activity.nestedTypes) {
187
+ nestedTypesMap.set(toPascalCase(nt.name), Array.isArray(nt.fields) ? nt.fields : []);
188
+ }
189
+ }
190
+ const outputMappings = outputFields.map((f) => {
191
+ // Match {entityName}Id → entity.getId()
192
+ if (f.name === entityIdAlias && aggregateInfo.rootFieldNames.has('id')) {
193
+ return { fieldName: f.name, accessor: 'entity.getId()' };
194
+ }
195
+ if (aggregateInfo.rootFieldNames.has(f.name)) {
196
+ const getter = 'entity.get' + f.name.charAt(0).toUpperCase() + f.name.slice(1) + '()';
197
+ const entityFieldType = aggregateInfo.rootFieldTypes ? aggregateInfo.rootFieldTypes.get(f.name) : null;
198
+ // Enum/VO → String: append .name() when output expects String but entity field is not a simple type
199
+ if (f.javaType === 'String' && entityFieldType && !SIMPLE_TYPES.has(entityFieldType)) {
200
+ return { fieldName: f.name, accessor: getter + '.name()' };
201
+ }
202
+ // VO → nestedType: when entity field is a VO and output expects a declared nestedType
203
+ const voFields = entityFieldType && aggregateInfo.voFieldsMap ? aggregateInfo.voFieldsMap.get(entityFieldType) : null;
204
+ const ntFields = nestedTypesMap.get(f.javaType);
205
+ if (voFields && ntFields) {
206
+ const ntArgs = ntFields.map((ntField) => {
207
+ const voField = voFields.find((vf) => vf.name === ntField.name);
208
+ return voField
209
+ ? `${getter}.get${ntField.name.charAt(0).toUpperCase() + ntField.name.slice(1)}()`
210
+ : `null /* TODO: derive ${ntField.name} */`;
211
+ });
212
+ return {
213
+ fieldName: f.name,
214
+ accessor: f.name,
215
+ preDeclaration: { varType: f.javaType, varName: f.name, getter, ntArgs },
216
+ };
217
+ }
218
+ return { fieldName: f.name, accessor: getter };
219
+ }
220
+ return { fieldName: f.name, accessor: `null /* TODO: derive ${f.name} from entity */` };
221
+ });
222
+ const voMappingImports = outputMappings
223
+ .filter((m) => m.preDeclaration)
224
+ .map((m) => (shared
225
+ ? `import ${packageName}.shared.domain.contracts.${moduleName}.${m.preDeclaration.varType};`
226
+ : `import ${packageName}.${moduleName}.application.dtos.temporal.${m.preDeclaration.varType};`));
227
+
228
+ // ── Eager load detection ──────────────────────────────────────────────
229
+ // If any output field is a List<...> whose name matches a LAZY relationship,
230
+ // we must use findByIdWith{Field} (JOIN FETCH) instead of findById.
231
+ let needsEagerLoad = false;
232
+ let eagerLoadField = null;
233
+ const lazyMap = aggregateInfo.lazyRelationshipFields;
234
+ if (lazyMap && lazyMap.size > 0) {
235
+ for (const f of outputFields) {
236
+ if (!f.javaType.startsWith('List<')) continue;
237
+ for (const [lazyFieldName, lazyInfo] of lazyMap.entries()) {
238
+ const lazyFieldPascal = lazyFieldName.charAt(0).toUpperCase() + lazyFieldName.slice(1);
239
+ const outputFieldPascal = f.name.charAt(0).toUpperCase() + f.name.slice(1);
240
+ // Direct name match OR the lazy field name ends with the output field name (PascalCase suffix)
241
+ if (f.name === lazyFieldName || lazyFieldPascal.endsWith(outputFieldPascal)) {
242
+ needsEagerLoad = true;
243
+ eagerLoadField = { jpaFieldName: lazyInfo.jpaFieldName, fieldPascal: lazyFieldPascal };
244
+ break;
245
+ }
246
+ }
247
+ if (needsEagerLoad) break;
248
+ }
249
+ }
250
+
251
+ queryMeta = {
252
+ aggregateName: aggregateInfo.aggregateName,
253
+ idFieldAccessor: idAccessor,
254
+ outputMappings,
255
+ voMappingImports,
256
+ needsEagerLoad,
257
+ eagerLoadField,
258
+ };
259
+ }
260
+
261
+ return {
262
+ packageName,
263
+ moduleName,
264
+ modulePascalCase,
265
+ activityPascalCase,
266
+ activityCategory,
267
+ categoryType,
268
+ hasInput,
269
+ hasOutput,
270
+ inputFields,
271
+ outputFields,
272
+ inputImports: resolveFieldImports(inputFields),
273
+ outputImports: resolveFieldImports(outputFields),
274
+ description: activity.description || '',
275
+ compensation: activity.compensation || null,
276
+ timeout: activity.timeout || null,
277
+ shared,
278
+ targetModule: moduleName,
279
+ isQueryActivity,
280
+ queryMeta,
281
+ ...buildNestedTypesContext(activity, packageName, moduleName, shared, inputFields, outputFields, sharedNestedTypes, existingSharedTypes),
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Extract custom type names referenced in field javaTypes that are NOT
287
+ * standard Java types (not in JAVA_TYPE_IMPORTS and not primitives).
288
+ * E.g., `List<OrderItemDetail>` → `OrderItemDetail`.
289
+ * @param {Array<{javaType: string}>} fields
290
+ * @returns {Set<string>} custom type names found
291
+ */
292
+ function extractCustomTypeNames(fields) {
293
+ const BUILTIN = new Set(['String', 'Integer', 'Long', 'Boolean', 'Double', 'Float', 'int', 'long', 'boolean', 'double', 'float', 'void',
294
+ ...Object.keys(JAVA_TYPE_IMPORTS)]);
295
+ const customNames = new Set();
296
+ const genericRe = /<([A-Z][A-Za-z0-9]*)>/;
297
+ for (const field of fields) {
298
+ if (!field.javaType) continue;
299
+ // Check inside generics: List<OrderItemDetail> → OrderItemDetail
300
+ const match = field.javaType.match(genericRe);
301
+ if (match && !BUILTIN.has(match[1])) {
302
+ customNames.add(match[1]);
303
+ }
304
+ // Check bare type: OrderItemDetail (not inside generic)
305
+ if (!match && !BUILTIN.has(field.javaType) && /^[A-Z]/.test(field.javaType)) {
306
+ customNames.add(field.javaType);
307
+ }
308
+ }
309
+ return customNames;
310
+ }
311
+
312
+ /**
313
+ * Build nestedTypes context from an activity's `nestedTypes` YAML section.
314
+ * Returns properties to merge into the activity context.
315
+ * @param {object} activity - Raw activity YAML
316
+ * @param {string} packageName
317
+ * @param {string} moduleName
318
+ * @param {boolean} shared
319
+ * @param {Array} inputFields - Already-mapped input fields
320
+ * @param {Array} outputFields - Already-mapped output fields
321
+ * @returns {object} { nestedTypes, nestedTypeImportLines }
322
+ */
323
+ function buildNestedTypesContext(activity, packageName, moduleName, shared, inputFields, outputFields, sharedNestedTypes = new Set(), existingSharedTypes = new Map()) {
324
+ const rawNested = Array.isArray(activity.nestedTypes) ? activity.nestedTypes : [];
325
+ const rawExternal = Array.isArray(activity.externalTypes) ? activity.externalTypes : [];
326
+
327
+ if (rawNested.length === 0 && rawExternal.length === 0) {
328
+ return { nestedTypes: [], nestedTypeImportLines: [], externalTypeMap: {} };
329
+ }
330
+
331
+ const nestedTypes = rawNested.map((nt) => {
332
+ const name = toPascalCase(nt.name);
333
+ const fields = Array.isArray(nt.fields) ? nt.fields.map(mapField) : [];
334
+ return {
335
+ name,
336
+ fields,
337
+ imports: resolveFieldImports(fields),
338
+ };
339
+ });
340
+
341
+ // Build external type map: typeName → sourceModule (camelCase)
342
+ const externalTypeMap = {};
343
+ for (const ext of rawExternal) {
344
+ const typeName = toPascalCase(ext.name);
345
+ const sourceModule = toCamelCase(ext.module);
346
+ externalTypeMap[typeName] = sourceModule;
347
+ }
348
+
349
+ // Determine which custom types are actually referenced by input/output fields
350
+ const allFields = [...inputFields, ...outputFields];
351
+ const referencedNames = extractCustomTypeNames(allFields);
352
+ const nestedTypeNames = new Set(nestedTypes.map((nt) => nt.name));
353
+
354
+ // Build import lines for custom types referenced in input/output
355
+ const importLines = [];
356
+ for (const typeName of referencedNames) {
357
+ if (nestedTypeNames.has(typeName)) {
358
+ // Check if an identical type already lives in shared/domain/contracts/ — if so, prefer it.
359
+ const alreadySharedModule = existingSharedTypes.get(typeName);
360
+ if (sharedNestedTypes.has(typeName) || shared) {
361
+ // Prefer the module that actually owns the type in shared (first-writer-wins).
362
+ // This ensures Input/Output files in module B import from module A's contracts
363
+ // when both declared the same nestedType but A was processed first.
364
+ const ownerModule = existingSharedTypes.get(typeName) || moduleName;
365
+ importLines.push(`import ${packageName}.shared.domain.contracts.${ownerModule}.${typeName};`);
366
+ } else if (alreadySharedModule) {
367
+ // Type already generated into shared by another activity — point there, not locally.
368
+ importLines.push(`import ${packageName}.shared.domain.contracts.${alreadySharedModule}.${typeName};`);
369
+ } else {
370
+ importLines.push(`import ${packageName}.${moduleName}.application.dtos.temporal.${typeName};`);
371
+ }
372
+ } else if (externalTypeMap[typeName]) {
373
+ // External type — always from source module's shared contracts
374
+ const sourceModule = externalTypeMap[typeName];
375
+ importLines.push(`import ${packageName}.shared.domain.contracts.${sourceModule}.${typeName};`);
376
+ }
377
+ }
378
+
379
+ return {
380
+ nestedTypes,
381
+ nestedTypeImportLines: importLines.sort(),
382
+ externalTypeMap,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Parse system.yaml and return a Set of activity names that are referenced
388
+ * cross-module (i.e. appear as activity or compensation in workflow steps).
389
+ * @param {string} projectDir
390
+ * @returns {Set<string>} activity names (PascalCase) that are cross-module
391
+ */
392
+ async function parseCrossModuleActivities(projectDir) {
393
+ const systemYamlPath = path.join(projectDir, 'system', 'system.yaml');
394
+ if (!(await fs.pathExists(systemYamlPath))) return new Set();
395
+
396
+ const content = await fs.readFile(systemYamlPath, 'utf-8');
397
+ const data = yaml.load(content);
398
+ if (!data || !Array.isArray(data.workflows)) return new Set();
399
+
400
+ const crossModuleSet = new Set();
401
+
402
+ for (const wf of data.workflows) {
403
+ if (!Array.isArray(wf.steps)) continue;
404
+ const hostModule = wf.trigger && wf.trigger.module
405
+ ? toCamelCase(wf.trigger.module)
406
+ : null;
407
+ for (const step of wf.steps) {
408
+ if (step.activity && step.target) {
409
+ const targetModule = toCamelCase(step.target);
410
+ // Only mark as cross-module if target differs from the workflow host
411
+ if (targetModule !== hostModule) {
412
+ crossModuleSet.add(toPascalCase(step.activity));
413
+ }
414
+ }
415
+ if (step.compensation) {
416
+ // Compensation targets same module as the step it compensates
417
+ const compTarget = step.target ? toCamelCase(step.target) : hostModule;
418
+ if (compTarget !== hostModule) {
419
+ crossModuleSet.add(toPascalCase(step.compensation));
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ return crossModuleSet;
426
+ }
427
+
428
+ /**
429
+ * Generate shared activity contracts (Interface + Input + Output) into
430
+ * shared/domain/contracts/{module}/. Called by YAML-driven generation
431
+ * and also exported for use by `eva g temporal-system`.
432
+ *
433
+ * @param {object} actCtx - Activity context from buildActivityContext()
434
+ * @param {string} sharedBasePath - Path to shared/ Java directory
435
+ * @param {object} writeOptions
436
+ * @returns {string[]} list of generated file paths (relative)
437
+ */
438
+ async function generateSharedContracts(actCtx, sharedBasePath, writeOptions, existingSharedTypes = new Map()) {
439
+ const templatesDir = path.join(__dirname, '..', '..', 'templates', 'temporal-activity');
440
+ const contractsDir = path.join(sharedBasePath, 'domain', 'contracts', actCtx.targetModule);
441
+ const generated = [];
442
+
443
+ // 1. Shared Input record
444
+ if (actCtx.hasInput) {
445
+ await renderAndWrite(
446
+ path.join(templatesDir, 'SharedActivityInput.java.ejs'),
447
+ path.join(contractsDir, `${actCtx.activityPascalCase}Input.java`),
448
+ { ...actCtx, imports: actCtx.inputImports },
449
+ writeOptions
450
+ );
451
+ generated.push(`shared/domain/contracts/${actCtx.targetModule}/${actCtx.activityPascalCase}Input.java`);
452
+ }
453
+
454
+ // 2. Shared Output record
455
+ if (actCtx.hasOutput) {
456
+ await renderAndWrite(
457
+ path.join(templatesDir, 'SharedActivityOutput.java.ejs'),
458
+ path.join(contractsDir, `${actCtx.activityPascalCase}Output.java`),
459
+ { ...actCtx, imports: actCtx.outputImports },
460
+ writeOptions
461
+ );
462
+ generated.push(`shared/domain/contracts/${actCtx.targetModule}/${actCtx.activityPascalCase}Output.java`);
463
+ }
464
+
465
+ // 3. Shared ActivityInterface
466
+ await renderAndWrite(
467
+ path.join(templatesDir, 'SharedActivityInterface.java.ejs'),
468
+ path.join(contractsDir, `${actCtx.activityPascalCase}Activity.java`),
469
+ actCtx,
470
+ writeOptions
471
+ );
472
+ generated.push(`shared/domain/contracts/${actCtx.targetModule}/${actCtx.activityPascalCase}Activity.java`);
473
+
474
+ // 4. Shared NestedType records
475
+ if (Array.isArray(actCtx.nestedTypes) && actCtx.nestedTypes.length > 0) {
476
+ for (const nt of actCtx.nestedTypes) {
477
+ // Skip if an identical type already lives in *another* module's shared contracts.
478
+ // This prevents incompatible-types errors when the same nestedType is declared
479
+ // in multiple module YAMLs (e.g. CartItemDetail in carts.yaml AND products.yaml).
480
+ const existingOwner = existingSharedTypes.get(nt.name);
481
+ if (existingOwner && existingOwner !== actCtx.targetModule) continue;
482
+ const ntPath = path.join(contractsDir, `${nt.name}.java`);
483
+ if (await fs.pathExists(ntPath)) continue; // Deduplicate within same module
484
+ await renderAndWrite(
485
+ path.join(templatesDir, 'SharedNestedType.java.ejs'),
486
+ ntPath,
487
+ { packageName: actCtx.packageName, targetModule: actCtx.targetModule, typeName: nt.name, fields: nt.fields, imports: nt.imports },
488
+ writeOptions
489
+ );
490
+ generated.push(`shared/domain/contracts/${actCtx.targetModule}/${nt.name}.java`);
491
+ // Update the map so subsequent activities in the same run see this type as already written.
492
+ existingSharedTypes.set(nt.name, actCtx.targetModule);
493
+ }
494
+ }
495
+
496
+ return generated;
497
+ }
498
+
12
499
  async function generateTemporalActivityCommand(moduleName, activityName, options = {}) {
13
500
  const projectDir = process.cwd();
14
501
 
@@ -46,7 +533,7 @@ async function generateTemporalActivityCommand(moduleName, activityName, options
46
533
  const { packageName } = projectConfig;
47
534
  const packagePath = toPackagePath(packageName);
48
535
 
49
- // Normalise module name to camelCase (system.yaml uses kebab-case, .eva4j.json stores camelCase)
536
+ // Normalise module name to camelCase
50
537
  moduleName = toCamelCase(moduleName);
51
538
 
52
539
  if (!(await configManager.moduleExists(moduleName))) {
@@ -60,6 +547,349 @@ async function generateTemporalActivityCommand(moduleName, activityName, options
60
547
  process.exit(1);
61
548
  }
62
549
 
550
+ const moduleBasePath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName);
551
+ const checksumManager = new ChecksumManager(moduleBasePath);
552
+ await checksumManager.load();
553
+ const writeOptions = { force: options.force, checksumManager };
554
+
555
+ // ── Try YAML-driven generation ──────────────────────────────────────────
556
+ const domainYamlPath = path.join(moduleBasePath, 'domain.yaml');
557
+ const yamlActivities = await parseActivitiesFromYaml(domainYamlPath);
558
+
559
+ if (yamlActivities) {
560
+ await generateFromYaml(yamlActivities, activityName, {
561
+ projectDir, packageName, packagePath, moduleName, moduleBasePath, checksumManager, writeOptions, options,
562
+ });
563
+ } else {
564
+ await generateInteractive(activityName, {
565
+ projectDir, packageName, packagePath, moduleName, moduleBasePath, checksumManager, writeOptions, options,
566
+ });
567
+ }
568
+
569
+ await checksumManager.save();
570
+ }
571
+
572
+ // ─── YAML-driven generation ─────────────────────────────────────────────────
573
+
574
+ /**
575
+ * Walk shared/domain/contracts/ and return a Map from PascalCase type simple name
576
+ * → camelCase sourceModule for every *.java file found.
577
+ * Used to detect when a local nestedType duplicates a type that already lives in shared.
578
+ * @param {string} sharedBasePath - absolute path to shared/ Java root
579
+ * @returns {Map<string, string>} simpleName → sourceModule
580
+ */
581
+ async function scanExistingSharedTypes(sharedBasePath) {
582
+ const result = new Map();
583
+ const contractsRoot = path.join(sharedBasePath, 'domain', 'contracts');
584
+ if (!(await fs.pathExists(contractsRoot))) return result;
585
+ let moduleDirs;
586
+ try { moduleDirs = await fs.readdir(contractsRoot); } catch { return result; }
587
+ for (const mod of moduleDirs) {
588
+ const modDir = path.join(contractsRoot, mod);
589
+ let stat;
590
+ try { stat = await fs.stat(modDir); } catch { continue; }
591
+ if (!stat.isDirectory()) continue;
592
+ let files;
593
+ try { files = await fs.readdir(modDir); } catch { continue; }
594
+ for (const file of files) {
595
+ if (!file.endsWith('.java')) continue;
596
+ const simpleName = file.replace('.java', '');
597
+ if (!result.has(simpleName)) result.set(simpleName, mod);
598
+ }
599
+ }
600
+ return result;
601
+ }
602
+
603
+ /**
604
+ * Scan all module domain.yaml files in the system/ directory for `externalTypes`
605
+ * references. Returns a Map from sourceModule (camelCase) → Set of type names
606
+ * (PascalCase) that are referenced externally and thus need shared contracts.
607
+ */
608
+ async function collectExternalTypeRefs(projectDir) {
609
+ const systemDir = path.join(projectDir, 'system');
610
+ if (!(await fs.pathExists(systemDir))) return new Map();
611
+
612
+ const refs = new Map();
613
+ let files;
614
+ try {
615
+ files = await fs.readdir(systemDir);
616
+ } catch {
617
+ return refs;
618
+ }
619
+ for (const file of files) {
620
+ if (file === 'system.yaml' || !file.endsWith('.yaml')) continue;
621
+ try {
622
+ const content = await fs.readFile(path.join(systemDir, file), 'utf-8');
623
+ const data = yaml.load(content);
624
+ if (!data || !Array.isArray(data.activities)) continue;
625
+ for (const act of data.activities) {
626
+ if (!Array.isArray(act.externalTypes)) continue;
627
+ for (const et of act.externalTypes) {
628
+ const sourceModule = toCamelCase(et.module);
629
+ const typeName = toPascalCase(et.name);
630
+ if (!refs.has(sourceModule)) refs.set(sourceModule, new Set());
631
+ refs.get(sourceModule).add(typeName);
632
+ }
633
+ }
634
+ } catch {
635
+ // Skip malformed YAML files
636
+ }
637
+ }
638
+ return refs;
639
+ }
640
+
641
+ /**
642
+ * Inject a `findByIdWith{Field}` method (with JOIN FETCH) into the 3 repository
643
+ * files when a query activity needs eager loading of a LAZY relationship.
644
+ * Idempotent: skips injection if the method already exists in the file.
645
+ *
646
+ * @param {object} actCtx - Activity context (must have queryMeta.eagerLoadField)
647
+ * @param {string} moduleBasePath
648
+ * @param {string} packageName
649
+ * @param {string} moduleName
650
+ */
651
+ async function injectEagerLoadMethods(actCtx, moduleBasePath, packageName, moduleName) {
652
+ const { aggregateName, eagerLoadField } = actCtx.queryMeta;
653
+ const { jpaFieldName, fieldPascal } = eagerLoadField;
654
+ const methodName = `findByIdWith${fieldPascal}`;
655
+ const entityLower = aggregateName.charAt(0).toLowerCase() + aggregateName.slice(1);
656
+
657
+ // 1. domain/repositories/{Entity}Repository.java
658
+ const repoPath = path.join(moduleBasePath, 'domain', 'repositories', `${aggregateName}Repository.java`);
659
+ if (await fs.pathExists(repoPath)) {
660
+ let content = await fs.readFile(repoPath, 'utf-8');
661
+ if (!content.includes(methodName)) {
662
+ const lastBrace = content.lastIndexOf('}');
663
+ const injection = `\n Optional<${aggregateName}> ${methodName}(String id);\n`;
664
+ content = content.slice(0, lastBrace) + injection + content.slice(lastBrace);
665
+ await fs.writeFile(repoPath, content, 'utf-8');
666
+ }
667
+ }
668
+
669
+ // 2. infrastructure/database/repositories/{Entity}JpaRepository.java
670
+ const jpaRepoPath = path.join(moduleBasePath, 'infrastructure', 'database', 'repositories', `${aggregateName}JpaRepository.java`);
671
+ if (await fs.pathExists(jpaRepoPath)) {
672
+ let content = await fs.readFile(jpaRepoPath, 'utf-8');
673
+ if (!content.includes(methodName)) {
674
+ // Inject @Query, @Param, and Optional imports if missing
675
+ const queryImport = 'import org.springframework.data.jpa.repository.Query;';
676
+ const paramImport = 'import org.springframework.data.repository.query.Param;';
677
+ const optionalImport = 'import java.util.Optional;';
678
+ if (!content.includes(queryImport)) {
679
+ content = content.replace(/^(import .+;)$/m, `$1\n${queryImport}`);
680
+ }
681
+ if (!content.includes(paramImport)) {
682
+ content = content.replace(/^(import .+;)$/m, `$1\n${paramImport}`);
683
+ }
684
+ if (!content.includes(optionalImport)) {
685
+ content = content.replace(/^(import .+;)$/m, `$1\n${optionalImport}`);
686
+ }
687
+ const lastBrace = content.lastIndexOf('}');
688
+ const queryStr = `SELECT ${entityLower} FROM ${aggregateName}Jpa ${entityLower} JOIN FETCH ${entityLower}.${jpaFieldName} WHERE ${entityLower}.id = :id`;
689
+ const injection = `\n @Query("${queryStr}")\n Optional<${aggregateName}Jpa> ${methodName}(@Param("id") String id);\n`;
690
+ content = content.slice(0, lastBrace) + injection + content.slice(lastBrace);
691
+ await fs.writeFile(jpaRepoPath, content, 'utf-8');
692
+ }
693
+ }
694
+
695
+ // 3. infrastructure/database/repositories/{Entity}RepositoryImpl.java
696
+ const implPath = path.join(moduleBasePath, 'infrastructure', 'database', 'repositories', `${aggregateName}RepositoryImpl.java`);
697
+ if (await fs.pathExists(implPath)) {
698
+ let content = await fs.readFile(implPath, 'utf-8');
699
+ if (!content.includes(methodName)) {
700
+ const lastBrace = content.lastIndexOf('}');
701
+ const injection = [
702
+ '',
703
+ ' @Override',
704
+ ` public Optional<${aggregateName}> ${methodName}(String id) {`,
705
+ ` return jpaRepository.${methodName}(id).map(mapper::toDomain);`,
706
+ ' }',
707
+ '',
708
+ ].join('\n');
709
+ content = content.slice(0, lastBrace) + injection + content.slice(lastBrace);
710
+ await fs.writeFile(implPath, content, 'utf-8');
711
+ }
712
+ }
713
+ }
714
+
715
+ async function generateFromYaml(yamlActivities, activityName, ctx) {
716
+ const { projectDir, packageName, packagePath, moduleName, moduleBasePath, writeOptions } = ctx;
717
+
718
+ // Detect which activities are cross-module from system.yaml
719
+ const crossModuleSet = await parseCrossModuleActivities(projectDir);
720
+
721
+ // Detect nestedTypes from this module that are referenced as externalTypes in other modules
722
+ const externalTypeRefs = await collectExternalTypeRefs(projectDir);
723
+ const sharedNestedTypes = externalTypeRefs.get(moduleName) || new Set();
724
+
725
+ // Load aggregate info for query activity detection
726
+ const domainYamlPath = path.join(moduleBasePath, 'domain.yaml');
727
+ const aggregateInfo = await parseAggregateInfo(domainYamlPath);
728
+
729
+ let activitiesToGenerate;
730
+
731
+ if (activityName) {
732
+ const match = yamlActivities.find(
733
+ (a) => toPascalCase(a.name) === toPascalCase(activityName)
734
+ );
735
+ if (!match) {
736
+ console.error(chalk.red(`āŒ Activity '${activityName}' not found in domain.yaml`));
737
+ console.error(chalk.gray('Available activities: ' + yamlActivities.map((a) => a.name).join(', ')));
738
+ process.exit(1);
739
+ }
740
+ activitiesToGenerate = [match];
741
+ } else if (ctx.options && ctx.options.generateAll) {
742
+ // Non-interactive: generate all activities (used by eva build)
743
+ activitiesToGenerate = yamlActivities;
744
+ } else {
745
+ // Prompt: generate all or select
746
+ const { selection } = await inquirer.prompt([{
747
+ type: 'list',
748
+ name: 'selection',
749
+ message: `Found ${yamlActivities.length} activities in domain.yaml:`,
750
+ choices: [
751
+ { name: 'Generate ALL activities', value: 'all' },
752
+ ...yamlActivities.map((a) => ({ name: ` ${a.name} (${a.type || 'light'})`, value: a.name })),
753
+ ],
754
+ }]);
755
+ activitiesToGenerate = selection === 'all'
756
+ ? yamlActivities
757
+ : [yamlActivities.find((a) => a.name === selection)];
758
+ }
759
+
760
+ const spinner = ora(`Generating ${activitiesToGenerate.length} activity(ies)...`).start();
761
+
762
+ try {
763
+ const templatesDir = path.join(__dirname, '..', '..', 'templates', 'temporal-activity');
764
+ const sharedBasePath = path.join(projectDir, 'src', 'main', 'java', packagePath, 'shared');
765
+
766
+ // Scan shared/domain/contracts/ for types already generated by other activities/modules.
767
+ // Used to prevent local nestedType files that duplicate a shared type (causing import collisions).
768
+ const existingSharedTypes = await scanExistingSharedTypes(sharedBasePath);
769
+
770
+ const generated = [];
771
+ const sharedGenerated = [];
772
+
773
+ for (const activity of activitiesToGenerate) {
774
+ const isShared = crossModuleSet.has(toPascalCase(activity.name));
775
+ const actCtx = buildActivityContext(activity, packageName, moduleName, isShared, aggregateInfo, sharedNestedTypes, existingSharedTypes);
776
+ spinner.text = `Generating ${actCtx.activityPascalCase}Activity${isShared ? ' (shared)' : ''}...`;
777
+
778
+ if (isShared) {
779
+ // Interface + Input + Output → shared/domain/contracts/{module}/
780
+ // Pass existingSharedTypes so generateSharedContracts can skip cross-module duplicates
781
+ // and update the map in-place for subsequent activities in the same run.
782
+ const sharedFiles = await generateSharedContracts(actCtx, sharedBasePath, writeOptions, existingSharedTypes);
783
+ sharedGenerated.push(...sharedFiles);
784
+ } else {
785
+ // Interface + Input + Output → {module}/application/
786
+ if (actCtx.hasInput) {
787
+ await renderAndWrite(
788
+ path.join(templatesDir, 'ActivityInput.java.ejs'),
789
+ path.join(moduleBasePath, 'application', 'dtos', 'temporal', `${actCtx.activityPascalCase}Input.java`),
790
+ { ...actCtx, imports: actCtx.inputImports },
791
+ writeOptions
792
+ );
793
+ }
794
+ if (actCtx.hasOutput) {
795
+ await renderAndWrite(
796
+ path.join(templatesDir, 'ActivityOutput.java.ejs'),
797
+ path.join(moduleBasePath, 'application', 'dtos', 'temporal', `${actCtx.activityPascalCase}Output.java`),
798
+ { ...actCtx, imports: actCtx.outputImports },
799
+ writeOptions
800
+ );
801
+ }
802
+ await renderAndWrite(
803
+ path.join(templatesDir, 'ActivityInterface.java.ejs'),
804
+ path.join(moduleBasePath, 'application', 'ports', `${actCtx.activityPascalCase}Activity.java`),
805
+ actCtx,
806
+ writeOptions
807
+ );
808
+ // Local NestedType records (or shared if externally referenced / already in shared)
809
+ if (Array.isArray(actCtx.nestedTypes) && actCtx.nestedTypes.length > 0) {
810
+ for (const nt of actCtx.nestedTypes) {
811
+ if (sharedNestedTypes.has(nt.name)) {
812
+ // Generate as shared contract — referenced by other modules via externalTypes
813
+ const contractsDir = path.join(sharedBasePath, 'domain', 'contracts', moduleName);
814
+ const ntPath = path.join(contractsDir, `${nt.name}.java`);
815
+ if (await fs.pathExists(ntPath)) continue; // Deduplicate
816
+ await renderAndWrite(
817
+ path.join(templatesDir, 'SharedNestedType.java.ejs'),
818
+ ntPath,
819
+ { packageName: actCtx.packageName, targetModule: actCtx.moduleName, typeName: nt.name, fields: nt.fields, imports: nt.imports },
820
+ writeOptions
821
+ );
822
+ sharedGenerated.push(`shared/domain/contracts/${moduleName}/${nt.name}.java`);
823
+ } else if (existingSharedTypes.has(nt.name)) {
824
+ // A type with the same simple name already lives in shared/domain/contracts/.
825
+ // Skip writing a local duplicate — the import in Output/Input already points to shared.
826
+ continue;
827
+ } else {
828
+ // Generate locally
829
+ const ntPath = path.join(moduleBasePath, 'application', 'dtos', 'temporal', `${nt.name}.java`);
830
+ if (await fs.pathExists(ntPath)) continue; // Deduplicate
831
+ await renderAndWrite(
832
+ path.join(templatesDir, 'NestedType.java.ejs'),
833
+ ntPath,
834
+ { packageName: actCtx.packageName, moduleName: actCtx.moduleName, typeName: nt.name, fields: nt.fields, imports: nt.imports },
835
+ writeOptions
836
+ );
837
+ }
838
+ }
839
+ }
840
+ }
841
+
842
+ // ActivityImpl always goes in the module
843
+ await renderAndWrite(
844
+ path.join(templatesDir, 'ActivityImpl.java.ejs'),
845
+ path.join(moduleBasePath, 'infrastructure', 'adapters', 'activities', `${actCtx.activityPascalCase}ActivityImpl.java`),
846
+ actCtx,
847
+ writeOptions
848
+ );
849
+
850
+ // Inject eager-load repository methods when a LAZY relationship is projected
851
+ if (actCtx.isQueryActivity && actCtx.queryMeta && actCtx.queryMeta.needsEagerLoad) {
852
+ await injectEagerLoadMethods(actCtx, moduleBasePath, packageName, moduleName);
853
+ }
854
+
855
+ generated.push(actCtx);
856
+ }
857
+
858
+ spinner.succeed(chalk.green(`āœ… ${generated.length} activity(ies) generated successfully`));
859
+
860
+ if (sharedGenerated.length > 0) {
861
+ console.log(chalk.blue('\nšŸ“ Shared contracts (cross-module):'));
862
+ for (const f of sharedGenerated) {
863
+ console.log(chalk.gray(` ${f}`));
864
+ }
865
+ }
866
+
867
+ console.log(chalk.blue('\nšŸ“ Module files:'));
868
+ for (const actCtx of generated) {
869
+ const name = actCtx.activityPascalCase;
870
+ if (!actCtx.shared) {
871
+ if (actCtx.hasInput) {
872
+ console.log(chalk.gray(` ${moduleName}/application/dtos/temporal/${name}Input.java`));
873
+ }
874
+ if (actCtx.hasOutput) {
875
+ console.log(chalk.gray(` ${moduleName}/application/dtos/temporal/${name}Output.java`));
876
+ }
877
+ console.log(chalk.gray(` ${moduleName}/application/ports/${name}Activity.java`));
878
+ }
879
+ console.log(chalk.gray(` ${moduleName}/infrastructure/adapters/activities/${name}ActivityImpl.java`));
880
+ }
881
+ } catch (error) {
882
+ spinner.fail(chalk.red('Failed to generate temporal activity'));
883
+ console.error(chalk.red(error.message));
884
+ process.exit(1);
885
+ }
886
+ }
887
+
888
+ // ─── Interactive generation (fallback when no domain.yaml activities) ────────
889
+
890
+ async function generateInteractive(activityName, ctx) {
891
+ const { projectDir, packageName, packagePath, moduleName, moduleBasePath, checksumManager, writeOptions, options } = ctx;
892
+
63
893
  // 1. Prompt activity name
64
894
  if (!activityName) {
65
895
  const answer = await inquirer.prompt([{
@@ -84,35 +914,83 @@ async function generateTemporalActivityCommand(moduleName, activityName, options
84
914
  ],
85
915
  }]);
86
916
 
87
- // 3. Discover existing workflows across all modules
917
+ // 3. Prompt for input fields
918
+ const inputFields = await promptFields('Input');
919
+ const hasInput = inputFields.length > 0;
920
+
921
+ // 4. Prompt for output fields
922
+ const outputFields = await promptFields('Output');
923
+ const hasOutput = outputFields.length > 0;
924
+
925
+ // 5. Discover existing workflows across all modules
88
926
  const javaRoot = path.join(projectDir, 'src', 'main', 'java', packagePath);
89
927
  const workflows = await findExistingWorkflows(javaRoot);
90
928
 
91
- if (workflows.length === 0) {
92
- console.error(chalk.red('āŒ No workflows found in this project'));
93
- console.error(chalk.gray('Create a workflow first using: eva generate temporal-flow <module>'));
94
- process.exit(1);
95
- }
929
+ let selectedWorkflow = null;
930
+ if (workflows.length > 0) {
931
+ const { doRegister } = await inquirer.prompt([{
932
+ type: 'confirm',
933
+ name: 'doRegister',
934
+ message: 'Register activity in an existing workflow?',
935
+ default: true,
936
+ }]);
96
937
 
97
- const { selectedWorkflow } = await inquirer.prompt([{
98
- type: 'list',
99
- name: 'selectedWorkflow',
100
- message: 'Register activity in workflow:',
101
- choices: workflows.map((w) => ({ name: `${w.moduleName} / ${w.implClass}`, value: w })),
102
- }]);
938
+ if (doRegister) {
939
+ const answer = await inquirer.prompt([{
940
+ type: 'list',
941
+ name: 'selectedWorkflow',
942
+ message: 'Register activity in workflow:',
943
+ choices: workflows.map((w) => ({ name: `${w.moduleName} / ${w.implClass}`, value: w })),
944
+ }]);
945
+ selectedWorkflow = answer.selectedWorkflow;
946
+ }
947
+ }
103
948
 
104
- const moduleBasePath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName);
105
- const checksumManager = new ChecksumManager(moduleBasePath);
106
- await checksumManager.load();
949
+ const modulePascalCase = toPascalCase(moduleName);
950
+ const moduleActivityCategory = `${modulePascalCase}${activityCategory}`;
951
+ const context = {
952
+ packageName,
953
+ moduleName,
954
+ modulePascalCase,
955
+ activityPascalCase,
956
+ activityCategory: moduleActivityCategory,
957
+ categoryType: activityCategory,
958
+ hasInput,
959
+ hasOutput,
960
+ inputFields,
961
+ outputFields,
962
+ inputImports: resolveFieldImports(inputFields),
963
+ outputImports: resolveFieldImports(outputFields),
964
+ };
107
965
 
108
966
  const spinner = ora(`Generating ${activityPascalCase}Activity...`).start();
109
967
 
110
968
  try {
111
- const context = { packageName, moduleName, activityPascalCase, activityCategory };
112
969
  const templatesDir = path.join(__dirname, '..', '..', 'templates', 'temporal-activity');
113
- const writeOptions = { force: options.force, checksumManager };
114
970
 
115
- // 4. Generate ActivityInterface in application/ports/
971
+ // Generate Input record
972
+ if (hasInput) {
973
+ spinner.text = `Generating ${activityPascalCase}Input...`;
974
+ await renderAndWrite(
975
+ path.join(templatesDir, 'ActivityInput.java.ejs'),
976
+ path.join(moduleBasePath, 'application', 'dtos', 'temporal', `${activityPascalCase}Input.java`),
977
+ { ...context, imports: context.inputImports },
978
+ writeOptions
979
+ );
980
+ }
981
+
982
+ // Generate Output record
983
+ if (hasOutput) {
984
+ spinner.text = `Generating ${activityPascalCase}Output...`;
985
+ await renderAndWrite(
986
+ path.join(templatesDir, 'ActivityOutput.java.ejs'),
987
+ path.join(moduleBasePath, 'application', 'dtos', 'temporal', `${activityPascalCase}Output.java`),
988
+ { ...context, imports: context.outputImports },
989
+ writeOptions
990
+ );
991
+ }
992
+
993
+ // Generate ActivityInterface
116
994
  spinner.text = `Generating ${activityPascalCase}Activity interface...`;
117
995
  await renderAndWrite(
118
996
  path.join(templatesDir, 'ActivityInterface.java.ejs'),
@@ -121,7 +999,7 @@ async function generateTemporalActivityCommand(moduleName, activityName, options
121
999
  writeOptions
122
1000
  );
123
1001
 
124
- // 5. Generate ActivityImpl in infrastructure/adapters/activities/
1002
+ // Generate ActivityImpl
125
1003
  spinner.text = `Generating ${activityPascalCase}ActivityImpl...`;
126
1004
  await renderAndWrite(
127
1005
  path.join(templatesDir, 'ActivityImpl.java.ejs'),
@@ -130,25 +1008,29 @@ async function generateTemporalActivityCommand(moduleName, activityName, options
130
1008
  writeOptions
131
1009
  );
132
1010
 
133
- // 6. Register activity stub in selected WorkFlowImpl
134
- spinner.text = `Registering activity in ${selectedWorkflow.implClass}...`;
135
- await registerActivityInWorkflow(
136
- selectedWorkflow.filePath,
137
- packageName,
138
- moduleName,
139
- activityPascalCase,
140
- activityCategory
141
- );
1011
+ // Register activity stub in selected WorkFlowImpl
1012
+ if (selectedWorkflow) {
1013
+ spinner.text = `Registering activity in ${selectedWorkflow.implClass}...`;
1014
+ await registerActivityInWorkflow(
1015
+ selectedWorkflow.filePath,
1016
+ packageName,
1017
+ moduleName,
1018
+ activityPascalCase,
1019
+ activityCategory
1020
+ );
1021
+ }
142
1022
 
143
1023
  spinner.succeed(chalk.green(`āœ… ${activityPascalCase}Activity generated successfully`));
144
1024
 
145
1025
  console.log(chalk.blue('\nšŸ“ Generated files:'));
1026
+ if (hasInput) console.log(chalk.gray(` ${moduleName}/application/dtos/temporal/${activityPascalCase}Input.java`));
1027
+ if (hasOutput) console.log(chalk.gray(` ${moduleName}/application/dtos/temporal/${activityPascalCase}Output.java`));
146
1028
  console.log(chalk.gray(` ${moduleName}/application/ports/${activityPascalCase}Activity.java`));
147
1029
  console.log(chalk.gray(` ${moduleName}/infrastructure/adapters/activities/${activityPascalCase}ActivityImpl.java`));
148
- console.log(chalk.blue('\nšŸ“ Updated files:'));
149
- console.log(chalk.gray(` ${selectedWorkflow.moduleName}/application/usecases/${selectedWorkflow.implClass}.java`));
150
-
151
- await checksumManager.save();
1030
+ if (selectedWorkflow) {
1031
+ console.log(chalk.blue('\nšŸ“ Updated files:'));
1032
+ console.log(chalk.gray(` ${selectedWorkflow.moduleName}/application/usecases/${selectedWorkflow.implClass}.java`));
1033
+ }
152
1034
  } catch (error) {
153
1035
  spinner.fail(chalk.red('Failed to generate temporal activity'));
154
1036
  console.error(chalk.red(error.message));
@@ -156,6 +1038,50 @@ async function generateTemporalActivityCommand(moduleName, activityName, options
156
1038
  }
157
1039
  }
158
1040
 
1041
+ // ─── Helpers ────────────────────────────────────────────────────────────────
1042
+
1043
+ const COMMON_JAVA_TYPES = ['String', 'Integer', 'Long', 'Boolean', 'BigDecimal', 'LocalDateTime', 'LocalDate', 'UUID', 'List'];
1044
+
1045
+ async function promptFields(label) {
1046
+ const fields = [];
1047
+ const { addFields } = await inquirer.prompt([{
1048
+ type: 'confirm',
1049
+ name: 'addFields',
1050
+ message: `Define ${label} fields?`,
1051
+ default: true,
1052
+ }]);
1053
+
1054
+ if (!addFields) return fields;
1055
+
1056
+ let adding = true;
1057
+ while (adding) {
1058
+ const { fieldName, fieldType, more } = await inquirer.prompt([
1059
+ {
1060
+ type: 'input',
1061
+ name: 'fieldName',
1062
+ message: ` ${label} field name:`,
1063
+ validate: (v) => (v.trim() ? true : 'Field name is required'),
1064
+ },
1065
+ {
1066
+ type: 'list',
1067
+ name: 'fieldType',
1068
+ message: ` ${label} field type:`,
1069
+ choices: COMMON_JAVA_TYPES,
1070
+ },
1071
+ {
1072
+ type: 'confirm',
1073
+ name: 'more',
1074
+ message: ` Add another ${label} field?`,
1075
+ default: false,
1076
+ },
1077
+ ]);
1078
+ fields.push({ name: fieldName.trim(), javaType: fieldType });
1079
+ adding = more;
1080
+ }
1081
+
1082
+ return fields;
1083
+ }
1084
+
159
1085
  async function findExistingWorkflows(javaRoot) {
160
1086
  const results = [];
161
1087
  if (!(await fs.pathExists(javaRoot))) return results;
@@ -229,3 +1155,11 @@ async function registerActivityInWorkflow(workflowImplPath, packageName, moduleN
229
1155
  }
230
1156
 
231
1157
  module.exports = generateTemporalActivityCommand;
1158
+ module.exports.generateSharedContracts = generateSharedContracts;
1159
+ module.exports.buildActivityContext = buildActivityContext;
1160
+ module.exports.scanExistingSharedTypes = scanExistingSharedTypes;
1161
+ module.exports.parseActivitiesFromYaml = parseActivitiesFromYaml;
1162
+ module.exports.parseCrossModuleActivities = parseCrossModuleActivities;
1163
+ module.exports.collectExternalTypeRefs = collectExternalTypeRefs;
1164
+ module.exports.resolveFieldImports = resolveFieldImports;
1165
+ module.exports.mapField = mapField;