eventmodeler 0.2.4 → 0.2.6

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/dist/index.js CHANGED
@@ -35,6 +35,7 @@ import { createStateChangeSlice } from './slices/create-state-change-slice/index
35
35
  import { createAutomationSlice } from './slices/create-automation-slice/index.js';
36
36
  import { createStateViewSlice } from './slices/create-state-view-slice/index.js';
37
37
  import { createFlow } from './slices/create-flow/index.js';
38
+ import { codegenSlice } from './slices/codegen-slice/index.js';
38
39
  const args = process.argv.slice(2);
39
40
  function getNamedArg(argList, ...names) {
40
41
  for (let i = 0; i < argList.length; i++) {
@@ -94,12 +95,14 @@ COMMANDS:
94
95
  create state-change-slice --xml <data>
95
96
  Create a state-change slice (Screen → Command → Event)
96
97
  create automation-slice --xml <data>
97
- Create an automation slice (Processor → Command → Event)
98
+ Create an automation slice (ReadModel → Processor → Command → Event)
98
99
  create state-view-slice --xml <data>
99
100
  Create a state-view slice (Read Model)
100
101
  create flow --from <source> --to <target>
101
102
  Create a flow between elements (Event→ReadModel, ReadModel→Screen/Processor)
102
103
 
104
+ codegen slice <name> Generate code-ready JSON for a slice (includes dependencies, mappings, scenarios)
105
+
103
106
  summary Show model summary statistics
104
107
 
105
108
  export json Export entire model as JSON
@@ -476,6 +479,24 @@ async function main() {
476
479
  process.exit(1);
477
480
  }
478
481
  break;
482
+ case 'codegen':
483
+ switch (subcommand) {
484
+ case 'slice': {
485
+ const sliceName = filteredArgs[2];
486
+ if (!sliceName) {
487
+ console.error('Error: slice name is required');
488
+ console.error('Usage: eventmodeler codegen slice <name>');
489
+ process.exit(1);
490
+ }
491
+ codegenSlice(model, sliceName);
492
+ break;
493
+ }
494
+ default:
495
+ console.error(`Unknown codegen target: ${subcommand}`);
496
+ console.error('Valid targets: slice');
497
+ process.exit(1);
498
+ }
499
+ break;
479
500
  default:
480
501
  console.error(`Unknown command: ${command}`);
481
502
  printHelp();
@@ -24,6 +24,12 @@ export declare const STATE_CHANGE_SLICE: {
24
24
  export declare const AUTOMATION_SLICE: {
25
25
  width: number;
26
26
  height: number;
27
+ readModel: {
28
+ width: number;
29
+ height: number;
30
+ offsetX: number;
31
+ offsetY: number;
32
+ };
27
33
  processor: {
28
34
  width: number;
29
35
  height: number;
@@ -7,11 +7,12 @@ export const STATE_CHANGE_SLICE = {
7
7
  event: { width: 160, height: 100, offsetX: 360, offsetY: 860 },
8
8
  };
9
9
  export const AUTOMATION_SLICE = {
10
- width: 560,
10
+ width: 880,
11
11
  height: 1000,
12
- processor: { width: 120, height: 120, offsetX: 40, offsetY: 120 },
13
- command: { width: 160, height: 100, offsetX: 180, offsetY: 460 },
14
- event: { width: 160, height: 100, offsetX: 360, offsetY: 860 },
12
+ readModel: { width: 160, height: 100, offsetX: 40, offsetY: 460 },
13
+ processor: { width: 120, height: 120, offsetX: 280, offsetY: 120 },
14
+ command: { width: 160, height: 100, offsetX: 440, offsetY: 460 },
15
+ event: { width: 160, height: 100, offsetX: 680, offsetY: 860 },
15
16
  };
16
17
  export const STATE_VIEW_SLICE = {
17
18
  width: 380,
@@ -824,138 +824,6 @@ function applyEvent(model, event) {
824
824
  case 'ScreenDesignUpdated':
825
825
  // Screen designs are visual-only, not relevant for CLI
826
826
  break;
827
- // Compound slice placement events - place entire slices atomically
828
- case 'StateChangeSlicePlaced': {
829
- // Create slice
830
- model.slices.set(event.sliceId, {
831
- id: event.sliceId,
832
- name: event.sliceName,
833
- status: 'created',
834
- position: event.slicePosition,
835
- size: event.sliceSize,
836
- nodeIds: [event.screenId, event.commandId, event.eventId],
837
- });
838
- // Create screen
839
- model.screens.set(event.screenId, {
840
- id: event.screenId,
841
- name: event.screenName,
842
- fields: event.screenFields,
843
- position: event.screenPosition,
844
- width: event.screenSize.width,
845
- height: event.screenSize.height,
846
- });
847
- // Create command
848
- model.commands.set(event.commandId, {
849
- id: event.commandId,
850
- name: event.commandName,
851
- fields: event.commandFields,
852
- position: event.commandPosition,
853
- width: event.commandSize.width,
854
- height: event.commandSize.height,
855
- });
856
- // Create event
857
- model.events.set(event.eventId, {
858
- id: event.eventId,
859
- name: event.eventName,
860
- fields: event.eventFields,
861
- position: event.eventPosition,
862
- width: event.eventSize.width,
863
- height: event.eventSize.height,
864
- });
865
- // Create screen->command flow
866
- model.flows.set(event.screenToCommandFlowId, {
867
- id: event.screenToCommandFlowId,
868
- flowType: 'ScreenToCommand',
869
- sourceId: event.screenId,
870
- targetId: event.commandId,
871
- fieldMappings: event.screenToCommandMappings,
872
- });
873
- // Create command->event flow
874
- model.flows.set(event.commandToEventFlowId, {
875
- id: event.commandToEventFlowId,
876
- flowType: 'CommandToEvent',
877
- sourceId: event.commandId,
878
- targetId: event.eventId,
879
- fieldMappings: event.commandToEventMappings,
880
- });
881
- break;
882
- }
883
- case 'AutomationSlicePlaced': {
884
- // Create slice
885
- model.slices.set(event.sliceId, {
886
- id: event.sliceId,
887
- name: event.sliceName,
888
- status: 'created',
889
- position: event.slicePosition,
890
- size: event.sliceSize,
891
- nodeIds: [event.processorId, event.commandId, event.eventId],
892
- });
893
- // Create processor
894
- model.processors.set(event.processorId, {
895
- id: event.processorId,
896
- name: event.processorName,
897
- fields: [],
898
- position: event.processorPosition,
899
- width: event.processorSize.width,
900
- height: event.processorSize.height,
901
- });
902
- // Create command
903
- model.commands.set(event.commandId, {
904
- id: event.commandId,
905
- name: event.commandName,
906
- fields: event.commandFields,
907
- position: event.commandPosition,
908
- width: event.commandSize.width,
909
- height: event.commandSize.height,
910
- });
911
- // Create event
912
- model.events.set(event.eventId, {
913
- id: event.eventId,
914
- name: event.eventName,
915
- fields: event.eventFields,
916
- position: event.eventPosition,
917
- width: event.eventSize.width,
918
- height: event.eventSize.height,
919
- });
920
- // Create processor->command flow
921
- model.flows.set(event.processorToCommandFlowId, {
922
- id: event.processorToCommandFlowId,
923
- flowType: 'ProcessorToCommand',
924
- sourceId: event.processorId,
925
- targetId: event.commandId,
926
- fieldMappings: [],
927
- });
928
- // Create command->event flow
929
- model.flows.set(event.commandToEventFlowId, {
930
- id: event.commandToEventFlowId,
931
- flowType: 'CommandToEvent',
932
- sourceId: event.commandId,
933
- targetId: event.eventId,
934
- fieldMappings: event.commandToEventMappings,
935
- });
936
- break;
937
- }
938
- case 'StateViewSlicePlaced': {
939
- // Create slice
940
- model.slices.set(event.sliceId, {
941
- id: event.sliceId,
942
- name: event.sliceName,
943
- status: 'created',
944
- position: event.slicePosition,
945
- size: event.sliceSize,
946
- nodeIds: [event.readModelId],
947
- });
948
- // Create read model
949
- model.readModels.set(event.readModelId, {
950
- id: event.readModelId,
951
- name: event.readModelName,
952
- fields: event.readModelFields,
953
- position: event.readModelPosition,
954
- width: event.readModelSize.width,
955
- height: event.readModelSize.height,
956
- });
957
- break;
958
- }
959
827
  }
960
828
  }
961
829
  // Merge propagated fields with existing fields:
@@ -207,15 +207,20 @@ export function addScenario(model, filePath, sliceName, input) {
207
207
  };
208
208
  }
209
209
  // Calculate position below slice
210
+ // Scenarios are tall (GWT rows with 100px stickies + labels + padding = ~400px+)
211
+ // so we need adequate spacing between them
212
+ const SCENARIO_GAP = 20; // Match slice gap
213
+ const SCENARIO_ESTIMATED_HEIGHT = 450; // Approximate rendered height for positioning
210
214
  const existingScenarios = [...model.scenarios.values()].filter(s => s.sliceId === slice.id);
211
215
  const sliceBottom = slice.position.y + slice.size.height;
212
216
  let positionY;
213
217
  if (existingScenarios.length === 0) {
214
- positionY = sliceBottom + 20;
218
+ positionY = sliceBottom + SCENARIO_GAP;
215
219
  }
216
220
  else {
217
- const lowestY = Math.max(...existingScenarios.map(s => s.position.y + s.height));
218
- positionY = lowestY + 10;
221
+ // Use estimated height since stored height (80px) doesn't reflect actual rendered size
222
+ const lowestY = Math.max(...existingScenarios.map(s => s.position.y + SCENARIO_ESTIMATED_HEIGHT));
223
+ positionY = lowestY + SCENARIO_GAP;
219
224
  }
220
225
  const position = {
221
226
  x: slice.position.x + 10,
@@ -0,0 +1,2 @@
1
+ import type { EventModel } from '../../types.js';
2
+ export declare function codegenSlice(model: EventModel, sliceName: string): void;
@@ -0,0 +1,384 @@
1
+ import { outputJson } from '../../lib/format.js';
2
+ import { findElementOrExit } from '../../lib/element-lookup.js';
3
+ // Get components inside a slice by checking if center point is within bounds
4
+ function getSliceComponents(model, slice) {
5
+ const bounds = {
6
+ left: slice.position.x,
7
+ right: slice.position.x + slice.size.width,
8
+ top: slice.position.y,
9
+ bottom: slice.position.y + slice.size.height,
10
+ };
11
+ function isInSlice(pos, width, height) {
12
+ const centerX = pos.x + width / 2;
13
+ const centerY = pos.y + height / 2;
14
+ return centerX >= bounds.left && centerX <= bounds.right && centerY >= bounds.top && centerY <= bounds.bottom;
15
+ }
16
+ return {
17
+ commands: [...model.commands.values()].filter(c => isInSlice(c.position, c.width, c.height)),
18
+ events: [...model.events.values()].filter(e => isInSlice(e.position, e.width, e.height)),
19
+ readModels: [...model.readModels.values()].filter(rm => isInSlice(rm.position, rm.width, rm.height)),
20
+ screens: [...model.screens.values()].filter(s => isInSlice(s.position, s.width, s.height)),
21
+ processors: [...model.processors.values()].filter(p => isInSlice(p.position, p.width, p.height)),
22
+ };
23
+ }
24
+ // Find which slice contains a node
25
+ function findSliceForNode(model, nodeId) {
26
+ const event = model.events.get(nodeId);
27
+ const readModel = model.readModels.get(nodeId);
28
+ const screen = model.screens.get(nodeId);
29
+ const command = model.commands.get(nodeId);
30
+ const processor = model.processors.get(nodeId);
31
+ const node = event ?? readModel ?? screen ?? command ?? processor;
32
+ if (!node)
33
+ return null;
34
+ for (const slice of model.slices.values()) {
35
+ const centerX = node.position.x + node.width / 2;
36
+ const centerY = node.position.y + node.height / 2;
37
+ if (centerX >= slice.position.x &&
38
+ centerX <= slice.position.x + slice.size.width &&
39
+ centerY >= slice.position.y &&
40
+ centerY <= slice.position.y + slice.size.height) {
41
+ return slice;
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ // Find which aggregate an event belongs to
47
+ function findAggregateForEvent(model, event) {
48
+ const centerX = event.position.x + event.width / 2;
49
+ const centerY = event.position.y + event.height / 2;
50
+ for (const aggregate of model.aggregates.values()) {
51
+ if (centerX >= aggregate.position.x &&
52
+ centerX <= aggregate.position.x + aggregate.size.width &&
53
+ centerY >= aggregate.position.y &&
54
+ centerY <= aggregate.position.y + aggregate.size.height) {
55
+ return aggregate.name;
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+ // Find which actor a screen belongs to
61
+ function findActorForScreen(model, screen) {
62
+ const centerX = screen.position.x + screen.width / 2;
63
+ const centerY = screen.position.y + screen.height / 2;
64
+ for (const actor of model.actors.values()) {
65
+ if (centerX >= actor.position.x &&
66
+ centerX <= actor.position.x + actor.size.width &&
67
+ centerY >= actor.position.y &&
68
+ centerY <= actor.position.y + actor.size.height) {
69
+ return actor.name;
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+ // Convert Field to JSON-friendly format
75
+ function fieldToJson(field) {
76
+ const result = {
77
+ name: field.name,
78
+ type: field.fieldType,
79
+ };
80
+ if (field.isList)
81
+ result.list = true;
82
+ if (field.isGenerated)
83
+ result.generated = true;
84
+ if (field.isOptional)
85
+ result.optional = true;
86
+ if (field.isUserInput)
87
+ result.userInput = true;
88
+ if (field.subfields && field.subfields.length > 0) {
89
+ result.subfields = field.subfields.map(fieldToJson);
90
+ }
91
+ return result;
92
+ }
93
+ // Determine slice type based on components, returns null for invalid configurations
94
+ function determineSliceType(hasProcessors, hasCommands, hasEvents, hasReadModels, hasScreens) {
95
+ // AUTOMATION: Must have a Processor (and typically ReadModel, Command, Event)
96
+ if (hasProcessors)
97
+ return 'AUTOMATION';
98
+ // STATE_CHANGE: Must have both Command and Event (typically with Screen)
99
+ if (hasCommands && hasEvents)
100
+ return 'STATE_CHANGE';
101
+ // STATE_VIEW: Must have at least one ReadModel
102
+ if (hasReadModels && !hasCommands && !hasEvents)
103
+ return 'STATE_VIEW';
104
+ // Invalid configurations:
105
+ // - Empty slice
106
+ // - Only Command (no Event)
107
+ // - Only Event (no Command)
108
+ // - Only Screen (no Command)
109
+ return null;
110
+ }
111
+ // Get element type for an ID
112
+ function getElementType(model, id) {
113
+ if (model.commands.has(id))
114
+ return 'COMMAND';
115
+ if (model.events.has(id))
116
+ return 'EVENT';
117
+ if (model.readModels.has(id))
118
+ return 'READMODEL';
119
+ if (model.screens.has(id))
120
+ return 'SCREEN';
121
+ if (model.processors.has(id))
122
+ return 'PROCESSOR';
123
+ return null;
124
+ }
125
+ // Get element name for an ID
126
+ function getElementName(model, id) {
127
+ return (model.commands.get(id)?.name ??
128
+ model.events.get(id)?.name ??
129
+ model.readModels.get(id)?.name ??
130
+ model.screens.get(id)?.name ??
131
+ model.processors.get(id)?.name ??
132
+ 'Unknown');
133
+ }
134
+ // Get element fields for an ID
135
+ function getElementFields(model, id) {
136
+ return (model.commands.get(id)?.fields ??
137
+ model.events.get(id)?.fields ??
138
+ model.readModels.get(id)?.fields ??
139
+ model.screens.get(id)?.fields ??
140
+ model.processors.get(id)?.fields ??
141
+ []);
142
+ }
143
+ // Find a field by ID within an element's fields
144
+ function findFieldById(fields, fieldId) {
145
+ for (const field of fields) {
146
+ if (field.id === fieldId)
147
+ return field;
148
+ if (field.subfields) {
149
+ const found = findFieldById(field.subfields, fieldId);
150
+ if (found)
151
+ return found;
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+ // Get element reference (type, id, name)
157
+ function getElementRef(model, id) {
158
+ return {
159
+ type: getElementType(model, id) ?? 'EVENT',
160
+ id,
161
+ name: getElementName(model, id),
162
+ };
163
+ }
164
+ // Get element with full schema
165
+ function getElementWithSchema(model, id) {
166
+ const type = getElementType(model, id) ?? 'EVENT';
167
+ const name = getElementName(model, id);
168
+ const fields = getElementFields(model, id).map(fieldToJson);
169
+ const result = { type, id, name, fields };
170
+ // Add aggregate info for events
171
+ const event = model.events.get(id);
172
+ if (event) {
173
+ const aggregate = findAggregateForEvent(model, event);
174
+ if (aggregate)
175
+ result.aggregate = aggregate;
176
+ const originSlice = findSliceForNode(model, id);
177
+ if (originSlice)
178
+ result.originSlice = originSlice.name;
179
+ }
180
+ // Add actor info for screens
181
+ const screen = model.screens.get(id);
182
+ if (screen) {
183
+ const actor = findActorForScreen(model, screen);
184
+ if (actor)
185
+ result.actor = actor;
186
+ }
187
+ return result;
188
+ }
189
+ // Enrich field mappings with field names
190
+ function enrichFieldMappings(model, flow) {
191
+ const sourceFields = getElementFields(model, flow.sourceId);
192
+ const targetFields = getElementFields(model, flow.targetId);
193
+ return flow.fieldMappings.map(mapping => {
194
+ const sourceField = findFieldById(sourceFields, mapping.sourceFieldId);
195
+ const targetField = findFieldById(targetFields, mapping.targetFieldId);
196
+ return {
197
+ sourceFieldId: mapping.sourceFieldId,
198
+ sourceFieldName: sourceField?.name ?? 'unknown',
199
+ targetFieldId: mapping.targetFieldId,
200
+ targetFieldName: targetField?.name ?? 'unknown',
201
+ };
202
+ });
203
+ }
204
+ // Get inbound dependencies (flows where target is in slice but source is not)
205
+ function getInboundDependencies(model, componentIds) {
206
+ return [...model.flows.values()]
207
+ .filter(f => componentIds.has(f.targetId) && !componentIds.has(f.sourceId))
208
+ .map(flow => ({
209
+ flowId: flow.id,
210
+ flowType: flow.flowType,
211
+ source: getElementWithSchema(model, flow.sourceId),
212
+ target: getElementRef(model, flow.targetId),
213
+ fieldMappings: enrichFieldMappings(model, flow),
214
+ }));
215
+ }
216
+ // Get internal flows (both source and target are in slice)
217
+ function getInternalFlows(model, componentIds) {
218
+ return [...model.flows.values()]
219
+ .filter(f => componentIds.has(f.sourceId) && componentIds.has(f.targetId))
220
+ .map(flow => ({
221
+ flowId: flow.id,
222
+ flowType: flow.flowType,
223
+ source: getElementRef(model, flow.sourceId),
224
+ target: getElementRef(model, flow.targetId),
225
+ fieldMappings: enrichFieldMappings(model, flow),
226
+ }));
227
+ }
228
+ // Format scenario then clause
229
+ function formatScenarioThen(model, then) {
230
+ if (then.type === 'error') {
231
+ return {
232
+ type: 'error',
233
+ errorType: then.errorType,
234
+ errorMessage: then.errorMessage,
235
+ };
236
+ }
237
+ if (then.type === 'events' && then.expectedEvents) {
238
+ return {
239
+ type: 'events',
240
+ expectedEvents: then.expectedEvents.map(expected => {
241
+ const event = model.events.get(expected.eventStickyId);
242
+ return {
243
+ eventId: expected.eventStickyId,
244
+ eventName: event?.name ?? 'UnknownEvent',
245
+ eventSchema: { fields: event?.fields.map(fieldToJson) ?? [] },
246
+ fieldValues: expected.fieldValues ?? {},
247
+ };
248
+ }),
249
+ };
250
+ }
251
+ if (then.type === 'readModelAssertion' && then.readModelAssertion) {
252
+ const rm = model.readModels.get(then.readModelAssertion.readModelStickyId);
253
+ return {
254
+ type: 'readModelAssertion',
255
+ readModelName: rm?.name ?? 'UnknownReadModel',
256
+ expectedFieldValues: then.readModelAssertion.expectedFieldValues,
257
+ };
258
+ }
259
+ // Default fallback
260
+ return { type: 'events', expectedEvents: [] };
261
+ }
262
+ // Format scenarios with resolved schemas
263
+ function formatScenarios(model, scenarios) {
264
+ return scenarios.map(scenario => ({
265
+ id: scenario.id,
266
+ name: scenario.name,
267
+ description: scenario.description,
268
+ given: scenario.givenEvents.map(ref => {
269
+ const event = model.events.get(ref.eventStickyId);
270
+ const originSlice = findSliceForNode(model, ref.eventStickyId);
271
+ return {
272
+ eventId: ref.eventStickyId,
273
+ eventName: event?.name ?? 'UnknownEvent',
274
+ originSlice: originSlice?.name ?? null,
275
+ eventSchema: { fields: event?.fields.map(fieldToJson) ?? [] },
276
+ fieldValues: ref.fieldValues ?? {},
277
+ };
278
+ }),
279
+ when: scenario.whenCommand
280
+ ? {
281
+ commandId: scenario.whenCommand.commandStickyId,
282
+ commandName: model.commands.get(scenario.whenCommand.commandStickyId)?.name ?? 'UnknownCommand',
283
+ fieldValues: scenario.whenCommand.fieldValues ?? {},
284
+ }
285
+ : null,
286
+ then: formatScenarioThen(model, scenario.then),
287
+ }));
288
+ }
289
+ export function codegenSlice(model, sliceName) {
290
+ // 1. Find the slice
291
+ const slice = findElementOrExit(model.slices, sliceName, 'slice');
292
+ // 2. Get components inside slice
293
+ const components = getSliceComponents(model, slice);
294
+ // 3. Build component ID set
295
+ const componentIds = new Set();
296
+ components.commands.forEach(c => componentIds.add(c.id));
297
+ components.events.forEach(e => componentIds.add(e.id));
298
+ components.readModels.forEach(rm => componentIds.add(rm.id));
299
+ components.screens.forEach(s => componentIds.add(s.id));
300
+ components.processors.forEach(p => componentIds.add(p.id));
301
+ // 4. Determine slice type
302
+ const sliceType = determineSliceType(components.processors.length > 0, components.commands.length > 0, components.events.length > 0, components.readModels.length > 0, components.screens.length > 0);
303
+ // Validate slice type - exit with error for invalid configurations
304
+ if (sliceType === null) {
305
+ const hasAny = componentIds.size > 0;
306
+ if (!hasAny) {
307
+ console.error(`Error: Slice "${slice.name}" is empty - no elements found inside slice bounds`);
308
+ }
309
+ else {
310
+ const parts = [];
311
+ if (components.commands.length > 0)
312
+ parts.push(`${components.commands.length} command(s)`);
313
+ if (components.events.length > 0)
314
+ parts.push(`${components.events.length} event(s)`);
315
+ if (components.screens.length > 0)
316
+ parts.push(`${components.screens.length} screen(s)`);
317
+ if (components.readModels.length > 0)
318
+ parts.push(`${components.readModels.length} read model(s)`);
319
+ console.error(`Error: Slice "${slice.name}" has invalid configuration for codegen: ${parts.join(', ')}`);
320
+ console.error('Valid configurations:');
321
+ console.error(' AUTOMATION: Processor + ReadModel + Command + Event');
322
+ console.error(' STATE_CHANGE: Screen + Command + Event');
323
+ console.error(' STATE_VIEW: ReadModel only');
324
+ }
325
+ process.exit(1);
326
+ }
327
+ // 5. Get inbound dependencies with full schemas
328
+ const inboundDependencies = getInboundDependencies(model, componentIds);
329
+ // 6. Get internal flows with field mappings
330
+ const internalFlows = getInternalFlows(model, componentIds);
331
+ // 7. Get scenarios for this slice
332
+ const scenarios = [...model.scenarios.values()].filter(s => s.sliceId === slice.id);
333
+ // 8. Build output
334
+ const output = {
335
+ sliceType,
336
+ slice: {
337
+ id: slice.id,
338
+ name: slice.name,
339
+ },
340
+ elements: {
341
+ readModels: components.readModels.map(rm => ({
342
+ id: rm.id,
343
+ name: rm.name,
344
+ fields: rm.fields.map(fieldToJson),
345
+ })),
346
+ processors: components.processors.map(p => ({
347
+ id: p.id,
348
+ name: p.name,
349
+ })),
350
+ commands: components.commands.map(cmd => ({
351
+ id: cmd.id,
352
+ name: cmd.name,
353
+ fields: cmd.fields.map(fieldToJson),
354
+ })),
355
+ events: components.events.map(evt => {
356
+ const result = {
357
+ id: evt.id,
358
+ name: evt.name,
359
+ fields: evt.fields.map(fieldToJson),
360
+ };
361
+ const aggregate = findAggregateForEvent(model, evt);
362
+ if (aggregate)
363
+ result.aggregate = aggregate;
364
+ return result;
365
+ }),
366
+ screens: components.screens.map(scr => {
367
+ const result = {
368
+ id: scr.id,
369
+ name: scr.name,
370
+ fields: scr.fields.map(fieldToJson),
371
+ };
372
+ const actor = findActorForScreen(model, scr);
373
+ if (actor)
374
+ result.actor = actor;
375
+ return result;
376
+ }),
377
+ },
378
+ inboundDependencies,
379
+ internalFlows,
380
+ scenarios: formatScenarios(model, scenarios),
381
+ };
382
+ // 9. Output JSON
383
+ outputJson(output);
384
+ }
@@ -16,6 +16,16 @@ function parseXmlInput(xml) {
16
16
  }
17
17
  const after = getAttr(sliceMatch[1], 'after');
18
18
  const before = getAttr(sliceMatch[1], 'before');
19
+ // Parse read-model
20
+ const readModelMatch = xml.match(/<read-model([^>]*)>([\s\S]*?)<\/read-model>/);
21
+ if (!readModelMatch) {
22
+ throw new Error('Invalid XML: missing <read-model> element');
23
+ }
24
+ const readModelName = getAttr(readModelMatch[1], 'name');
25
+ if (!readModelName) {
26
+ throw new Error('Invalid XML: read-model must have a name attribute');
27
+ }
28
+ const readModelFields = parseFieldsFromXml(readModelMatch[2]);
19
29
  // Parse processor (no fields)
20
30
  const processorMatch = xml.match(/<processor([^>]*?)(?:\/>|>([\s\S]*?)<\/processor>)/);
21
31
  if (!processorMatch) {
@@ -49,6 +59,7 @@ function parseXmlInput(xml) {
49
59
  sliceName,
50
60
  after,
51
61
  before,
62
+ readModel: { name: readModelName, fields: readModelFields },
52
63
  processor: { name: processorName },
53
64
  command: { name: commandName, fields: commandFields },
54
65
  event: { name: eventName, fields: eventFields },
@@ -99,17 +110,25 @@ export function createAutomationSlice(model, filePath, xmlInput) {
99
110
  }
100
111
  // Generate IDs
101
112
  const sliceId = crypto.randomUUID();
113
+ const readModelId = crypto.randomUUID();
102
114
  const processorId = crypto.randomUUID();
103
115
  const commandId = crypto.randomUUID();
104
116
  const eventId = crypto.randomUUID();
117
+ const readModelToProcessorFlowId = crypto.randomUUID();
105
118
  const processorToCommandFlowId = crypto.randomUUID();
106
119
  const commandToEventFlowId = crypto.randomUUID();
107
120
  // Convert field inputs to fields with IDs
121
+ const readModelFields = input.readModel.fields.map(fieldInputToField);
108
122
  const commandFields = input.command.fields.map(fieldInputToField);
109
123
  const eventFields = input.event.fields.map(fieldInputToField);
110
- // Infer field mappings (processor has no fields, so only command->event)
124
+ // Infer field mappings
125
+ const readModelToCommandMappings = inferFieldMappings(readModelFields, commandFields);
111
126
  const commandToEventMappings = inferFieldMappings(commandFields, eventFields);
112
127
  // Calculate absolute positions for components within the slice
128
+ const readModelPosition = {
129
+ x: slicePosition.x + AUTOMATION_SLICE.readModel.offsetX,
130
+ y: slicePosition.y + AUTOMATION_SLICE.readModel.offsetY,
131
+ };
113
132
  const processorPosition = {
114
133
  x: slicePosition.x + AUTOMATION_SLICE.processor.offsetX,
115
134
  y: slicePosition.y + AUTOMATION_SLICE.processor.offsetY,
@@ -132,7 +151,26 @@ export function createAutomationSlice(model, filePath, xmlInput) {
132
151
  size: { width: AUTOMATION_SLICE.width, height: AUTOMATION_SLICE.height },
133
152
  timestamp: Date.now(),
134
153
  });
135
- // 2. Create the processor
154
+ // 2. Create the read model
155
+ appendEvent(filePath, {
156
+ type: 'ReadModelStickyPlaced',
157
+ readModelStickyId: readModelId,
158
+ name: input.readModel.name,
159
+ position: readModelPosition,
160
+ width: AUTOMATION_SLICE.readModel.width,
161
+ height: AUTOMATION_SLICE.readModel.height,
162
+ timestamp: Date.now(),
163
+ });
164
+ // 3. Add read model fields
165
+ for (const field of readModelFields) {
166
+ appendEvent(filePath, {
167
+ type: 'ReadModelFieldAdded',
168
+ readModelStickyId: readModelId,
169
+ field,
170
+ timestamp: Date.now(),
171
+ });
172
+ }
173
+ // 4. Create the processor
136
174
  appendEvent(filePath, {
137
175
  type: 'ProcessorPlaced',
138
176
  processorId,
@@ -142,7 +180,7 @@ export function createAutomationSlice(model, filePath, xmlInput) {
142
180
  height: AUTOMATION_SLICE.processor.height,
143
181
  timestamp: Date.now(),
144
182
  });
145
- // 3. Create the command
183
+ // 5. Create the command
146
184
  appendEvent(filePath, {
147
185
  type: 'CommandStickyPlaced',
148
186
  commandStickyId: commandId,
@@ -152,7 +190,7 @@ export function createAutomationSlice(model, filePath, xmlInput) {
152
190
  height: AUTOMATION_SLICE.command.height,
153
191
  timestamp: Date.now(),
154
192
  });
155
- // 4. Add command fields
193
+ // 6. Add command fields
156
194
  for (const field of commandFields) {
157
195
  appendEvent(filePath, {
158
196
  type: 'CommandFieldAdded',
@@ -161,7 +199,7 @@ export function createAutomationSlice(model, filePath, xmlInput) {
161
199
  timestamp: Date.now(),
162
200
  });
163
201
  }
164
- // 5. Create the event
202
+ // 7. Create the event
165
203
  appendEvent(filePath, {
166
204
  type: 'EventStickyPlaced',
167
205
  eventStickyId: eventId,
@@ -171,7 +209,7 @@ export function createAutomationSlice(model, filePath, xmlInput) {
171
209
  height: AUTOMATION_SLICE.event.height,
172
210
  timestamp: Date.now(),
173
211
  });
174
- // 6. Add event fields
212
+ // 8. Add event fields
175
213
  for (const field of eventFields) {
176
214
  appendEvent(filePath, {
177
215
  type: 'EventFieldAdded',
@@ -180,7 +218,26 @@ export function createAutomationSlice(model, filePath, xmlInput) {
180
218
  timestamp: Date.now(),
181
219
  });
182
220
  }
183
- // 7. Create processor -> command flow
221
+ // 9. Create read model -> processor flow
222
+ appendEvent(filePath, {
223
+ type: 'ReadModelToProcessorFlowSpecified',
224
+ flowId: readModelToProcessorFlowId,
225
+ readModelStickyId: readModelId,
226
+ processorId,
227
+ sourceHandle: 'top-source',
228
+ targetHandle: 'bottom-target',
229
+ timestamp: Date.now(),
230
+ });
231
+ // 10. Add read model -> command field mappings
232
+ if (readModelToCommandMappings.length > 0) {
233
+ appendEvent(filePath, {
234
+ type: 'FieldMappingSpecified',
235
+ flowId: readModelToProcessorFlowId,
236
+ mappings: readModelToCommandMappings,
237
+ timestamp: Date.now(),
238
+ });
239
+ }
240
+ // 11. Create processor -> command flow
184
241
  appendEvent(filePath, {
185
242
  type: 'ProcessorToCommandFlowSpecified',
186
243
  flowId: processorToCommandFlowId,
@@ -190,7 +247,7 @@ export function createAutomationSlice(model, filePath, xmlInput) {
190
247
  targetHandle: 'top-target',
191
248
  timestamp: Date.now(),
192
249
  });
193
- // 8. Create command -> event flow
250
+ // 12. Create command -> event flow
194
251
  appendEvent(filePath, {
195
252
  type: 'CommandToEventFlowSpecified',
196
253
  flowId: commandToEventFlowId,
@@ -200,7 +257,7 @@ export function createAutomationSlice(model, filePath, xmlInput) {
200
257
  targetHandle: 'top-target',
201
258
  timestamp: Date.now(),
202
259
  });
203
- // 9. Add command -> event field mappings
260
+ // 13. Add command -> event field mappings
204
261
  if (commandToEventMappings.length > 0) {
205
262
  appendEvent(filePath, {
206
263
  type: 'FieldMappingSpecified',
@@ -210,8 +267,10 @@ export function createAutomationSlice(model, filePath, xmlInput) {
210
267
  });
211
268
  }
212
269
  console.log(`Created automation slice "${input.sliceName}"`);
270
+ console.log(` ReadModel: ${input.readModel.name} (${readModelFields.length} fields)`);
213
271
  console.log(` Processor: ${input.processor.name}`);
214
272
  console.log(` Command: ${input.command.name} (${commandFields.length} fields)`);
215
273
  console.log(` Event: ${input.event.name} (${eventFields.length} fields)`);
274
+ console.log(` ReadModel -> Command mappings: ${readModelToCommandMappings.length}`);
216
275
  console.log(` Command -> Event mappings: ${commandToEventMappings.length}`);
217
276
  }
@@ -23,7 +23,7 @@ function findElementByName(model, name) {
23
23
  }
24
24
  for (const rm of model.readModels.values()) {
25
25
  if (rm.id.toLowerCase().startsWith(idSearch)) {
26
- return { element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel' }, ambiguous: [] };
26
+ return { element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel', canonicalId: rm.canonicalId }, ambiguous: [] };
27
27
  }
28
28
  }
29
29
  for (const scr of model.screens.values()) {
@@ -49,7 +49,7 @@ function findElementByName(model, name) {
49
49
  return { element: { id: evt.id, name: evt.name, fields: evt.fields, type: 'event' }, ambiguous: [] };
50
50
  const rm = model.readModels.get(name);
51
51
  if (rm)
52
- return { element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel' }, ambiguous: [] };
52
+ return { element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel', canonicalId: rm.canonicalId }, ambiguous: [] };
53
53
  const scr = model.screens.get(name);
54
54
  if (scr)
55
55
  return { element: { id: scr.id, name: scr.name, fields: scr.fields, type: 'screen' }, ambiguous: [] };
@@ -73,7 +73,7 @@ function findElementByName(model, name) {
73
73
  }
74
74
  for (const rm of model.readModels.values()) {
75
75
  if (rm.name.toLowerCase() === nameLower) {
76
- matches.push({ element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel' }, type: 'readModel' });
76
+ matches.push({ element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel', canonicalId: rm.canonicalId }, type: 'readModel' });
77
77
  }
78
78
  }
79
79
  for (const scr of model.screens.values()) {
@@ -123,6 +123,95 @@ function flattenFields(fields, prefix = '') {
123
123
  }
124
124
  return result;
125
125
  }
126
+ /**
127
+ * Calculates completeness for a read model using the union approach.
128
+ * A field is satisfied if ANY incoming event provides it.
129
+ * For linked copies, considers flows into ALL nodes in the canonical group.
130
+ */
131
+ function calculateReadModelUnionCompleteness(model, readModelId, readModelFields, canonicalId) {
132
+ // Find all node IDs in the canonical group (original + all copies)
133
+ let targetNodeIds;
134
+ if (canonicalId) {
135
+ targetNodeIds = [...model.readModels.values()]
136
+ .filter(rm => rm.canonicalId === canonicalId)
137
+ .map(rm => rm.id);
138
+ }
139
+ else {
140
+ targetNodeIds = [readModelId];
141
+ }
142
+ // Find all incoming EventToReadModel flows to ANY node in the group
143
+ const incomingFlows = [...model.flows.values()].filter(f => targetNodeIds.includes(f.targetId) && f.flowType === 'EventToReadModel');
144
+ // Collect all source fields from all incoming events (union)
145
+ const allSourceFields = [];
146
+ const allManualMappings = [];
147
+ const sourceEventNames = [];
148
+ for (const flow of incomingFlows) {
149
+ const sourceFields = getSourceFields(model, flow.sourceId);
150
+ allSourceFields.push(...flattenFields(sourceFields));
151
+ if (flow.fieldMappings) {
152
+ allManualMappings.push(...flow.fieldMappings);
153
+ }
154
+ // Get event name for reporting
155
+ const event = model.events.get(flow.sourceId);
156
+ if (event && !sourceEventNames.includes(event.name)) {
157
+ sourceEventNames.push(event.name);
158
+ }
159
+ }
160
+ // Check each target field against the union of all sources
161
+ const flatTarget = flattenFields(readModelFields);
162
+ const untraceableFields = [];
163
+ const optionalMissingFields = [];
164
+ const fieldStatuses = [];
165
+ for (const { id, path, field } of flatTarget) {
166
+ // 1. Generated fields don't need a source
167
+ if (field.isGenerated) {
168
+ fieldStatuses.push({ fieldId: id, fieldName: path, status: 'generated' });
169
+ continue;
170
+ }
171
+ // 2. Check if there's a manual mapping from any flow
172
+ const manualMapping = allManualMappings.find(m => m.targetFieldId === id);
173
+ if (manualMapping) {
174
+ const sourceField = allSourceFields.find(sf => sf.id === manualMapping.sourceFieldId);
175
+ fieldStatuses.push({
176
+ fieldId: id,
177
+ fieldName: path,
178
+ status: 'satisfied',
179
+ sourceFieldId: manualMapping.sourceFieldId,
180
+ sourceFieldName: sourceField?.path,
181
+ });
182
+ continue;
183
+ }
184
+ // 3. Check auto-mapping by path match from any source
185
+ const autoMatch = allSourceFields.find(sf => sf.path === path);
186
+ if (autoMatch) {
187
+ fieldStatuses.push({
188
+ fieldId: id,
189
+ fieldName: path,
190
+ status: 'satisfied',
191
+ sourceFieldId: autoMatch.id,
192
+ sourceFieldName: autoMatch.path,
193
+ });
194
+ continue;
195
+ }
196
+ // 4. Optional fields missing is a warning, not an error
197
+ if (field.isOptional) {
198
+ optionalMissingFields.push(path);
199
+ fieldStatuses.push({ fieldId: id, fieldName: path, status: 'optional-missing' });
200
+ continue;
201
+ }
202
+ // Field is untraceable (required but no source)
203
+ untraceableFields.push(path);
204
+ fieldStatuses.push({ fieldId: id, fieldName: path, status: 'unsatisfied' });
205
+ }
206
+ return {
207
+ isComplete: untraceableFields.length === 0,
208
+ hasWarnings: optionalMissingFields.length > 0,
209
+ untraceableFields,
210
+ optionalMissingFields,
211
+ sourceEvents: sourceEventNames,
212
+ fieldStatuses,
213
+ };
214
+ }
126
215
  function calculateFlowCompleteness(model, flow, targetFields, sourceFields) {
127
216
  const flatSource = flattenFields(sourceFields);
128
217
  const flatTarget = flattenFields(targetFields);
@@ -254,10 +343,112 @@ export function showCompleteness(model, elementName, format) {
254
343
  }
255
344
  process.exit(1);
256
345
  }
257
- // Find all incoming flows for this element type
346
+ const elementTypeLabel = element.type === 'readModel' ? 'read-model' : element.type;
347
+ // For read models, use union completeness (all incoming events combined)
348
+ if (element.type === 'readModel') {
349
+ const unionResult = calculateReadModelUnionCompleteness(model, element.id, element.fields, element.canonicalId);
350
+ // Check if there are any incoming flows at all
351
+ let targetNodeIds;
352
+ if (element.canonicalId) {
353
+ targetNodeIds = [...model.readModels.values()]
354
+ .filter(rm => rm.canonicalId === element.canonicalId)
355
+ .map(rm => rm.id);
356
+ }
357
+ else {
358
+ targetNodeIds = [element.id];
359
+ }
360
+ const hasIncomingFlows = [...model.flows.values()].some(f => targetNodeIds.includes(f.targetId) && f.flowType === 'EventToReadModel');
361
+ if (!hasIncomingFlows) {
362
+ if (format === 'json') {
363
+ outputJson({
364
+ elementType: elementTypeLabel,
365
+ name: element.name,
366
+ message: `No flows into this ${elementTypeLabel}`
367
+ });
368
+ }
369
+ else {
370
+ console.log(`<completeness ${elementTypeLabel}="${escapeXml(element.name)}">`);
371
+ console.log(` <no-flows>No flows into this ${elementTypeLabel}</no-flows>`);
372
+ console.log('</completeness>');
373
+ }
374
+ return;
375
+ }
376
+ const overallStatus = unionResult.isComplete ? 'complete' : 'incomplete';
377
+ const flatTargetFields = flattenFields(element.fields);
378
+ if (format === 'json') {
379
+ outputJson({
380
+ elementType: elementTypeLabel,
381
+ name: element.name,
382
+ status: overallStatus,
383
+ ...(unionResult.hasWarnings ? { hasWarnings: true } : {}),
384
+ sourceEvents: unionResult.sourceEvents,
385
+ ...(unionResult.untraceableFields.length > 0 ? { untraceableFields: unionResult.untraceableFields } : {}),
386
+ ...(unionResult.optionalMissingFields.length > 0 ? { optionalMissingFields: unionResult.optionalMissingFields } : {}),
387
+ fieldStatuses: unionResult.fieldStatuses.map(f => ({
388
+ name: f.fieldName,
389
+ status: f.status,
390
+ ...(f.sourceFieldName ? { source: f.sourceFieldName } : {})
391
+ })),
392
+ elementFields: flatTargetFields.map(({ id, path, field }) => ({
393
+ id,
394
+ name: path,
395
+ type: field.fieldType,
396
+ ...(field.isList ? { isList: true } : {}),
397
+ ...(field.isOptional ? { isOptional: true } : {}),
398
+ ...(field.isGenerated ? { isGenerated: true } : {})
399
+ }))
400
+ });
401
+ return;
402
+ }
403
+ // XML output for read models
404
+ console.log(`<completeness ${elementTypeLabel}="${escapeXml(element.name)}" status="${overallStatus}"${unionResult.hasWarnings ? ' hasWarnings="true"' : ''}>`);
405
+ console.log(' <source-events>');
406
+ for (const eventName of unionResult.sourceEvents) {
407
+ console.log(` <event name="${escapeXml(eventName)}"/>`);
408
+ }
409
+ console.log(' </source-events>');
410
+ if (unionResult.untraceableFields.length > 0) {
411
+ console.log(' <untraceable-fields>');
412
+ for (const fieldName of unionResult.untraceableFields) {
413
+ console.log(` <field name="${escapeXml(fieldName)}"/>`);
414
+ }
415
+ console.log(' </untraceable-fields>');
416
+ }
417
+ if (unionResult.optionalMissingFields.length > 0) {
418
+ console.log(' <optional-missing-fields>');
419
+ for (const fieldName of unionResult.optionalMissingFields) {
420
+ console.log(` <field name="${escapeXml(fieldName)}"/>`);
421
+ }
422
+ console.log(' </optional-missing-fields>');
423
+ }
424
+ console.log(' <field-statuses>');
425
+ for (const field of unionResult.fieldStatuses) {
426
+ if (field.sourceFieldName) {
427
+ console.log(` <field name="${escapeXml(field.fieldName)}" status="${field.status}" source="${escapeXml(field.sourceFieldName)}"/>`);
428
+ }
429
+ else {
430
+ console.log(` <field name="${escapeXml(field.fieldName)}" status="${field.status}"/>`);
431
+ }
432
+ }
433
+ console.log(' </field-statuses>');
434
+ console.log(` <${elementTypeLabel}-fields>`);
435
+ for (const { id, path, field } of flatTargetFields) {
436
+ const attrs = [`id="${id}"`, `name="${escapeXml(path)}"`, `type="${field.fieldType}"`];
437
+ if (field.isList)
438
+ attrs.push('isList="true"');
439
+ if (field.isOptional)
440
+ attrs.push('isOptional="true"');
441
+ if (field.isGenerated)
442
+ attrs.push('isGenerated="true"');
443
+ console.log(` <field ${attrs.join(' ')}/>`);
444
+ }
445
+ console.log(` </${elementTypeLabel}-fields>`);
446
+ console.log('</completeness>');
447
+ return;
448
+ }
449
+ // For non-read-model elements, use per-flow completeness
258
450
  const validFlowTypes = INCOMING_FLOW_TYPES[element.type];
259
451
  const incomingFlows = [...model.flows.values()].filter(f => f.targetId === element.id && validFlowTypes.includes(f.flowType));
260
- const elementTypeLabel = element.type === 'readModel' ? 'read-model' : element.type;
261
452
  if (incomingFlows.length === 0) {
262
453
  if (format === 'json') {
263
454
  outputJson({
@@ -367,14 +558,22 @@ export function showModelCompleteness(model, format) {
367
558
  const incompleteFlows = [];
368
559
  let completeCount = 0;
369
560
  let totalCount = 0;
561
+ // For read models, we use union completeness - track which canonical groups we've processed
562
+ const processedReadModelGroups = new Set();
563
+ const incompleteReadModels = [];
564
+ let readModelCompleteCount = 0;
565
+ let readModelTotalCount = 0;
370
566
  for (const flow of allFlows) {
567
+ // Skip EventToReadModel flows - we handle read models separately with union logic
568
+ if (flow.flowType === 'EventToReadModel') {
569
+ continue;
570
+ }
371
571
  // Get target element info
372
572
  let targetName = 'Unknown';
373
573
  let targetType = 'unknown';
374
574
  let targetFields = [];
375
575
  const targetCmd = model.commands.get(flow.targetId);
376
576
  const targetEvt = model.events.get(flow.targetId);
377
- const targetRm = model.readModels.get(flow.targetId);
378
577
  const targetScr = model.screens.get(flow.targetId);
379
578
  const targetProc = model.processors.get(flow.targetId);
380
579
  if (targetCmd) {
@@ -387,11 +586,6 @@ export function showModelCompleteness(model, format) {
387
586
  targetType = 'event';
388
587
  targetFields = targetEvt.fields;
389
588
  }
390
- else if (targetRm) {
391
- targetName = targetRm.name;
392
- targetType = 'read-model';
393
- targetFields = targetRm.fields;
394
- }
395
589
  else if (targetScr) {
396
590
  targetName = targetScr.name;
397
591
  targetType = 'screen';
@@ -423,13 +617,51 @@ export function showModelCompleteness(model, format) {
423
617
  });
424
618
  }
425
619
  }
620
+ // Process read models with union completeness
621
+ // Group read models by canonicalId (or use id if no canonicalId)
622
+ for (const rm of model.readModels.values()) {
623
+ const groupKey = rm.canonicalId ?? rm.id;
624
+ // Skip if we've already processed this canonical group
625
+ if (processedReadModelGroups.has(groupKey)) {
626
+ continue;
627
+ }
628
+ processedReadModelGroups.add(groupKey);
629
+ // Check if this read model has any incoming EventToReadModel flows
630
+ let targetNodeIds;
631
+ if (rm.canonicalId) {
632
+ targetNodeIds = [...model.readModels.values()]
633
+ .filter(r => r.canonicalId === rm.canonicalId)
634
+ .map(r => r.id);
635
+ }
636
+ else {
637
+ targetNodeIds = [rm.id];
638
+ }
639
+ const hasIncomingFlows = allFlows.some(f => targetNodeIds.includes(f.targetId) && f.flowType === 'EventToReadModel');
640
+ if (!hasIncomingFlows) {
641
+ // No incoming flows - skip this read model
642
+ continue;
643
+ }
644
+ readModelTotalCount++;
645
+ const unionResult = calculateReadModelUnionCompleteness(model, rm.id, rm.fields, rm.canonicalId);
646
+ if (unionResult.isComplete) {
647
+ readModelCompleteCount++;
648
+ }
649
+ else {
650
+ incompleteReadModels.push({
651
+ name: rm.name,
652
+ untraceableFields: unionResult.untraceableFields,
653
+ sourceEvents: unionResult.sourceEvents,
654
+ });
655
+ }
656
+ }
426
657
  const incompleteCount = totalCount - completeCount;
658
+ const readModelIncompleteCount = readModelTotalCount - readModelCompleteCount;
427
659
  if (format === 'json') {
428
660
  outputJson({
429
661
  summary: {
430
- complete: completeCount,
431
- incomplete: incompleteCount,
432
- total: totalCount
662
+ complete: completeCount + readModelCompleteCount,
663
+ incomplete: incompleteCount + readModelIncompleteCount,
664
+ total: totalCount + readModelTotalCount
433
665
  },
434
666
  incompleteFlows: incompleteFlows.map(item => ({
435
667
  from: item.sourceName,
@@ -437,12 +669,19 @@ export function showModelCompleteness(model, format) {
437
669
  targetType: item.targetType,
438
670
  flowType: item.flow.flowType,
439
671
  unsatisfiedFields: item.unsatisfiedFields
440
- }))
672
+ })),
673
+ ...(incompleteReadModels.length > 0 ? {
674
+ incompleteReadModels: incompleteReadModels.map(item => ({
675
+ name: item.name,
676
+ untraceableFields: item.untraceableFields,
677
+ sourceEvents: item.sourceEvents
678
+ }))
679
+ } : {})
441
680
  });
442
681
  return;
443
682
  }
444
683
  console.log('<model-completeness>');
445
- console.log(` <summary complete="${completeCount}" incomplete="${incompleteCount}" total="${totalCount}"/>`);
684
+ console.log(` <summary complete="${completeCount + readModelCompleteCount}" incomplete="${incompleteCount + readModelIncompleteCount}" total="${totalCount + readModelTotalCount}"/>`);
446
685
  if (incompleteFlows.length > 0) {
447
686
  console.log(' <incomplete-flows>');
448
687
  for (const item of incompleteFlows) {
@@ -458,5 +697,23 @@ export function showModelCompleteness(model, format) {
458
697
  }
459
698
  console.log(' </incomplete-flows>');
460
699
  }
700
+ if (incompleteReadModels.length > 0) {
701
+ console.log(' <incomplete-read-models>');
702
+ for (const item of incompleteReadModels) {
703
+ console.log(` <read-model name="${escapeXml(item.name)}">`);
704
+ console.log(' <untraceable-fields>');
705
+ for (const fieldName of item.untraceableFields) {
706
+ console.log(` <field name="${escapeXml(fieldName)}"/>`);
707
+ }
708
+ console.log(' </untraceable-fields>');
709
+ console.log(' <source-events>');
710
+ for (const eventName of item.sourceEvents) {
711
+ console.log(` <event name="${escapeXml(eventName)}"/>`);
712
+ }
713
+ console.log(' </source-events>');
714
+ console.log(' </read-model>');
715
+ }
716
+ console.log(' </incomplete-read-models>');
717
+ }
461
718
  console.log('</model-completeness>');
462
719
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eventmodeler",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "CLI tool for interacting with Event Model files - query, update, and export event models from the terminal",
5
5
  "type": "module",
6
6
  "bin": {