@syntax-syllogism/flow-delta 0.0.1

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/cli.js ADDED
@@ -0,0 +1,1353 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { execFileSync as execFileSync2 } from "node:child_process";
7
+ import { parseArgs } from "node:util";
8
+
9
+ // src/parser/flow_parser.ts
10
+ import { Parser } from "xml2js";
11
+ var FAULT = "Fault";
12
+ var END = "End";
13
+ var START = "FLOW_START";
14
+ var END_NODE = {
15
+ name: "END",
16
+ label: END,
17
+ locationX: 0,
18
+ locationY: 0,
19
+ elementSubtype: END,
20
+ description: END
21
+ };
22
+ var ERROR_MESSAGES = {
23
+ couldNotFindConnectedNode: (node) => `Could not find connected node for ${node}`,
24
+ flowStartNotDefined: "Flow start is not defined"
25
+ };
26
+ var FlowParser = class {
27
+ constructor(flowXml) {
28
+ this.flowXml = flowXml;
29
+ this.beingParsed = {
30
+ nameToNode: /* @__PURE__ */ new Map()
31
+ };
32
+ }
33
+ flowXml;
34
+ beingParsed;
35
+ async generateFlowDefinition() {
36
+ const flowFromXml = await this.parseXmlFile();
37
+ return this.generateParsedFlow(flowFromXml.Flow);
38
+ }
39
+ parseXmlFile() {
40
+ return new Promise((resolve2, reject) => {
41
+ new Parser({ explicitArray: false }).parseString(
42
+ this.flowXml,
43
+ (err, result) => {
44
+ if (err) {
45
+ reject(err);
46
+ } else {
47
+ resolve2(result);
48
+ }
49
+ }
50
+ );
51
+ });
52
+ }
53
+ generateParsedFlow(flow) {
54
+ this.populateFlowNodes(flow);
55
+ this.beingParsed.nameToNode = this.generateNameToNodeMap();
56
+ this.beingParsed.transitions = this.populateTransitions();
57
+ return this.beingParsed;
58
+ }
59
+ populateFlowNodes(flow) {
60
+ this.beingParsed.label = flow.label;
61
+ this.beingParsed.fullName = flow.fullName;
62
+ this.beingParsed.processType = flow.processType;
63
+ this.beingParsed.start = flow.start;
64
+ this.validateFlowStart();
65
+ setFlowStart(this.beingParsed.start);
66
+ this.beingParsed.apexPluginCalls = ensureArray(flow.apexPluginCalls);
67
+ this.beingParsed.assignments = ensureArray(flow.assignments);
68
+ setAssignments(this.beingParsed.assignments);
69
+ this.beingParsed.collectionProcessors = ensureArray(
70
+ flow.collectionProcessors
71
+ );
72
+ this.beingParsed.customErrors = ensureArray(flow.customErrors);
73
+ setCustomErrorMessages(this.beingParsed.customErrors);
74
+ this.beingParsed.decisions = ensureArray(flow.decisions);
75
+ setDecisionRules(this.beingParsed.decisions);
76
+ this.beingParsed.loops = ensureArray(flow.loops);
77
+ this.beingParsed.orchestratedStages = ensureArray(flow.orchestratedStages);
78
+ setOrchestratedStageSteps(this.beingParsed.orchestratedStages);
79
+ this.beingParsed.recordCreates = ensureArray(flow.recordCreates);
80
+ this.beingParsed.recordDeletes = ensureArray(flow.recordDeletes);
81
+ this.beingParsed.recordLookups = ensureArray(flow.recordLookups);
82
+ setRecordLookups(this.beingParsed.recordLookups);
83
+ this.beingParsed.recordRollbacks = ensureArray(flow.recordRollbacks);
84
+ this.beingParsed.recordUpdates = ensureArray(flow.recordUpdates);
85
+ setRecordUpdates(this.beingParsed.recordUpdates);
86
+ this.beingParsed.screens = ensureArray(flow.screens);
87
+ this.beingParsed.steps = ensureArray(flow.steps);
88
+ this.beingParsed.subflows = ensureArray(flow.subflows);
89
+ this.beingParsed.transforms = ensureArray(flow.transforms);
90
+ this.beingParsed.waits = ensureArray(flow.waits);
91
+ this.beingParsed.actionCalls = ensureArray(flow.actionCalls);
92
+ }
93
+ /**
94
+ * Generates a map of node names to node objects.
95
+ *
96
+ * Node name contains the API name of a flow node and is unique across
97
+ * all elements within the flow.
98
+ * Here we make a special case for the END node, which is not defined in the
99
+ * XML.
100
+ */
101
+ generateNameToNodeMap() {
102
+ const nameToNode = /* @__PURE__ */ new Map();
103
+ nameToNode.set(END_NODE.name, END_NODE);
104
+ nameToNode.set(START, this.beingParsed.start);
105
+ const toProcess = [
106
+ this.beingParsed.apexPluginCalls,
107
+ this.beingParsed.assignments,
108
+ this.beingParsed.collectionProcessors,
109
+ this.beingParsed.customErrors,
110
+ this.beingParsed.decisions,
111
+ this.beingParsed.loops,
112
+ this.beingParsed.orchestratedStages,
113
+ this.beingParsed.recordCreates,
114
+ this.beingParsed.recordDeletes,
115
+ this.beingParsed.recordLookups,
116
+ this.beingParsed.recordRollbacks,
117
+ this.beingParsed.recordUpdates,
118
+ this.beingParsed.screens,
119
+ this.beingParsed.steps,
120
+ this.beingParsed.subflows,
121
+ this.beingParsed.transforms,
122
+ this.beingParsed.waits,
123
+ this.beingParsed.actionCalls
124
+ ];
125
+ for (const nodeArray of toProcess) {
126
+ if (!nodeArray) {
127
+ continue;
128
+ }
129
+ for (const node of nodeArray) {
130
+ nameToNode.set(node.name, node);
131
+ }
132
+ }
133
+ return nameToNode;
134
+ }
135
+ validateFlowStart() {
136
+ if (!this.beingParsed.start) {
137
+ throw new Error(ERROR_MESSAGES.flowStartNotDefined);
138
+ }
139
+ this.beingParsed.start.name = START;
140
+ }
141
+ /**
142
+ * Populate Transitions
143
+ *
144
+ * This populates the transitions represented in the Flow by performing a
145
+ * breadth first search from the start node.
146
+ * Cycles are detected and skipped by referencing the visited node names.
147
+ */
148
+ populateTransitions() {
149
+ const result = [];
150
+ const start = this.beingParsed.start;
151
+ if (!start) {
152
+ return result;
153
+ }
154
+ const queue = [start];
155
+ const visitedNodes = /* @__PURE__ */ new Set();
156
+ while (queue.length > 0) {
157
+ const node = queue.shift();
158
+ if (!node || visitedNodes.has(node.name)) {
159
+ continue;
160
+ }
161
+ visitedNodes.add(node.name);
162
+ const transitions = this.getTransitionsForNode(node);
163
+ for (const transition of transitions) {
164
+ const toNode2 = this.beingParsed.nameToNode?.get(transition.to);
165
+ if (toNode2) {
166
+ queue.push(toNode2);
167
+ }
168
+ }
169
+ result.push(...transitions);
170
+ }
171
+ return result;
172
+ }
173
+ /**
174
+ * Get the collection of transitions for a given node
175
+ *
176
+ * Unfortunately, there are multiple different properties which reference a
177
+ * `flowTypes.FlowConnector` and it is not possible to determine which
178
+ * property is being referenced without inspecting the XML.
179
+ *
180
+ * This method inspects all possible properties based on the type of the node
181
+ * and returns the transitions for the node.
182
+ */
183
+ getTransitionsForNode(node) {
184
+ const transitions = [];
185
+ if (isFlowStart(node)) {
186
+ transitions.push(...this.getTransitionsFromFlowStart(node));
187
+ } else if (isRecordCreate(node) || isRecordDelete(node) || isRecordLookup(node) || isRecordUpdate(node) || isApexPluginCall(node) || isFlowActionCall(node)) {
188
+ transitions.push(...this.getTransitionsFromConnectorOrFault(node));
189
+ } else if (isStep(node)) {
190
+ transitions.push(...this.getTransitionsFromStep(node));
191
+ } else if (isDecision(node)) {
192
+ transitions.push(...this.getTransitionsFromDecision(node));
193
+ } else if (isWait(node)) {
194
+ transitions.push(...this.getTransitionsFromWait(node));
195
+ } else if (isLoop(node)) {
196
+ transitions.push(...this.getTransitionsFromLoop(node));
197
+ } else if (isAssignment(node) || isCollectionProcessor(node) || isScreen(node) || isSubflow(node) || isTransform(node) || isRecordRollback(node) || isCustomError(node)) {
198
+ transitions.push(...this.getTransitionsFromConnector(node));
199
+ }
200
+ return transitions;
201
+ }
202
+ createTransition(from, connection, isFault, transitionLabel) {
203
+ const connectedNode = this.beingParsed.nameToNode?.get(
204
+ connection.targetReference
205
+ );
206
+ if (!connectedNode) {
207
+ throw new Error(
208
+ ERROR_MESSAGES.couldNotFindConnectedNode(connection.targetReference)
209
+ );
210
+ }
211
+ return {
212
+ from: from.name,
213
+ to: connectedNode.name,
214
+ fault: isFault,
215
+ label: transitionLabel
216
+ };
217
+ }
218
+ getTransitionsFromDecision(node) {
219
+ const result = [];
220
+ if (node.defaultConnector) {
221
+ result.push(
222
+ this.createTransition(
223
+ node,
224
+ node.defaultConnector,
225
+ false,
226
+ node.defaultConnectorLabel
227
+ )
228
+ );
229
+ }
230
+ if (node.rules) {
231
+ for (const rule of node.rules) {
232
+ if (rule && rule.connector) {
233
+ result.push(
234
+ this.createTransition(node, rule.connector, false, rule.label)
235
+ );
236
+ }
237
+ }
238
+ }
239
+ return result;
240
+ }
241
+ getTransitionsFromStep(node) {
242
+ const result = [];
243
+ if (node.connectors) {
244
+ for (const connector of node.connectors) {
245
+ result.push(this.createTransition(node, connector, false, void 0));
246
+ }
247
+ }
248
+ return result;
249
+ }
250
+ getTransitionsFromConnectorOrFault(node) {
251
+ const result = [];
252
+ if (node.connector) {
253
+ result.push(
254
+ this.createTransition(node, node.connector, false, void 0)
255
+ );
256
+ }
257
+ if (node.faultConnector) {
258
+ result.push(
259
+ this.createTransition(node, node.faultConnector, true, FAULT)
260
+ );
261
+ }
262
+ return result;
263
+ }
264
+ getTransitionsFromLoop(node) {
265
+ const result = [];
266
+ if (node.nextValueConnector) {
267
+ result.push(
268
+ this.createTransition(node, node.nextValueConnector, false, "for each")
269
+ );
270
+ }
271
+ if (node.noMoreValuesConnector) {
272
+ result.push(
273
+ this.createTransition(
274
+ node,
275
+ node.noMoreValuesConnector,
276
+ false,
277
+ "after all"
278
+ )
279
+ );
280
+ }
281
+ return result;
282
+ }
283
+ getTransitionsFromConnector(node) {
284
+ const result = [];
285
+ if (node.connector) {
286
+ for (const connector of Array.isArray(node.connector) ? node.connector : [node.connector]) {
287
+ result.push(this.createTransition(node, connector, false, void 0));
288
+ }
289
+ }
290
+ return result;
291
+ }
292
+ getTransitionsFromWait(node) {
293
+ const result = [];
294
+ if (node.defaultConnector) {
295
+ result.push(
296
+ this.createTransition(
297
+ node,
298
+ node.defaultConnector,
299
+ false,
300
+ node.defaultConnectorLabel
301
+ )
302
+ );
303
+ }
304
+ if (node.faultConnector) {
305
+ result.push(
306
+ this.createTransition(node, node.faultConnector, true, FAULT)
307
+ );
308
+ }
309
+ return result;
310
+ }
311
+ getTransitionsFromFlowStart(node) {
312
+ const result = [];
313
+ if (node.connector) {
314
+ result.push(
315
+ this.createTransition(node, node.connector, false, void 0)
316
+ );
317
+ }
318
+ if (node.scheduledPaths && node.scheduledPaths.length > 0) {
319
+ for (const scheduledPath of node.scheduledPaths) {
320
+ if (scheduledPath.connector) {
321
+ const label = scheduledPath.pathType;
322
+ result.push(
323
+ this.createTransition(node, scheduledPath.connector, false, label)
324
+ );
325
+ }
326
+ }
327
+ }
328
+ return result;
329
+ }
330
+ };
331
+ function ensureArray(input) {
332
+ return input ? Array.isArray(input) ? input : [input] : input;
333
+ }
334
+ function setAssignments(assignments) {
335
+ assignments?.forEach((assignment) => {
336
+ assignment.assignmentItems = ensureArray(
337
+ assignment.assignmentItems
338
+ );
339
+ });
340
+ }
341
+ function setOrchestratedStageSteps(orchestratedStages) {
342
+ orchestratedStages?.forEach((stage) => {
343
+ if (!stage.stageSteps) {
344
+ return;
345
+ }
346
+ stage.stageSteps = ensureArray(
347
+ stage.stageSteps
348
+ );
349
+ });
350
+ }
351
+ function setDecisionRules(decisions) {
352
+ decisions?.forEach((decision) => {
353
+ if (!decision.rules) {
354
+ return;
355
+ }
356
+ decision.rules = ensureArray(decision.rules);
357
+ setRuleConditions(decision.rules);
358
+ });
359
+ }
360
+ function setRuleConditions(rules) {
361
+ rules?.forEach((rule) => {
362
+ if (!rule.conditions) {
363
+ return;
364
+ }
365
+ rule.conditions = ensureArray(rule.conditions);
366
+ });
367
+ }
368
+ function setRecordLookups(recordLookups) {
369
+ recordLookups?.forEach((recordLookup) => {
370
+ if (recordLookup.filters) {
371
+ recordLookup.filters = ensureArray(
372
+ recordLookup.filters
373
+ );
374
+ }
375
+ if (recordLookup.queriedFields) {
376
+ recordLookup.queriedFields = ensureArray(
377
+ recordLookup.queriedFields
378
+ );
379
+ }
380
+ });
381
+ }
382
+ function setRecordUpdates(recordUpdates) {
383
+ if (!recordUpdates) {
384
+ return;
385
+ }
386
+ for (const recordUpdate of recordUpdates) {
387
+ recordUpdate.filters = ensureArray(
388
+ recordUpdate.filters ?? []
389
+ );
390
+ recordUpdate.inputAssignments = ensureArray(
391
+ recordUpdate.inputAssignments ?? []
392
+ );
393
+ }
394
+ }
395
+ function setCustomErrorMessages(customErrors) {
396
+ if (!customErrors) {
397
+ return;
398
+ }
399
+ for (const customError of customErrors) {
400
+ customError.customErrorMessages = ensureArray(
401
+ customError.customErrorMessages
402
+ );
403
+ }
404
+ }
405
+ function setFlowStart(start) {
406
+ if (!start) {
407
+ return;
408
+ }
409
+ if (start.filters) {
410
+ start.filters = ensureArray(start.filters);
411
+ }
412
+ if (start.scheduledPaths) {
413
+ start.scheduledPaths = ensureArray(
414
+ start.scheduledPaths
415
+ );
416
+ }
417
+ if (start.capabilityTypes) {
418
+ start.capabilityTypes = ensureArray(
419
+ start.capabilityTypes
420
+ );
421
+ }
422
+ }
423
+ function isAssignment(node) {
424
+ return node.assignmentItems !== void 0;
425
+ }
426
+ function isCollectionProcessor(node) {
427
+ return node.collectionProcessorType !== void 0;
428
+ }
429
+ function isScreen(node) {
430
+ return node.fields !== void 0;
431
+ }
432
+ function isSubflow(node) {
433
+ return node.flowName !== void 0;
434
+ }
435
+ function isTransform(node) {
436
+ return node.dataType !== void 0;
437
+ }
438
+ function isStep(node) {
439
+ return node.connectors !== void 0;
440
+ }
441
+ function isDecision(node) {
442
+ return node.rules !== void 0;
443
+ }
444
+ function isWait(node) {
445
+ return node.waitEvents !== void 0;
446
+ }
447
+ function isLoop(node) {
448
+ return node.nextValueConnector !== void 0;
449
+ }
450
+ function isRecordCreate(node) {
451
+ const recordCreate = node;
452
+ const hasInputAssignments = recordCreate.inputAssignments !== void 0 && Array.isArray(recordCreate.inputAssignments);
453
+ const hasObject = recordCreate.object !== void 0;
454
+ const recordUpdate = node;
455
+ const hasNoFilters = recordUpdate.filters === void 0;
456
+ return hasInputAssignments && hasObject && hasNoFilters;
457
+ }
458
+ function isRecordDelete(node) {
459
+ return node.inputReference !== void 0;
460
+ }
461
+ function isRecordLookup(node) {
462
+ return node.filters !== void 0;
463
+ }
464
+ function isRecordUpdate(node) {
465
+ const recordUpdate = node;
466
+ return recordUpdate.inputAssignments !== void 0 && recordUpdate.filters !== void 0 && recordUpdate.object !== void 0;
467
+ }
468
+ function isRecordRollback(node) {
469
+ return node.connector !== void 0;
470
+ }
471
+ function isApexPluginCall(node) {
472
+ return node.apexClass !== void 0;
473
+ }
474
+ function isFlowActionCall(node) {
475
+ return node.actionName !== void 0;
476
+ }
477
+ function isCustomError(node) {
478
+ return node.customErrorMessages !== void 0;
479
+ }
480
+ function isFlowStart(node) {
481
+ return node.name === START && node.label === void 0;
482
+ }
483
+
484
+ // src/model/build-model.ts
485
+ var EDGE_KEYS = /* @__PURE__ */ new Set([
486
+ "connector",
487
+ "faultConnector",
488
+ "defaultConnector",
489
+ "nextValueConnector",
490
+ "noMoreValuesConnector"
491
+ ]);
492
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
493
+ "name",
494
+ "label",
495
+ "locationX",
496
+ "locationY",
497
+ "elementSubtype",
498
+ "diffStatus"
499
+ ]);
500
+ var UNORDERED_ARRAY_KEYS = /* @__PURE__ */ new Set([
501
+ "capabilityTypes",
502
+ "choiceReferences",
503
+ "dataTypeMappings",
504
+ "filters",
505
+ "inputParameters",
506
+ "outputParameters",
507
+ "processMetadataValues"
508
+ ]);
509
+ var NODE_TYPE_BY_COLLECTION = {
510
+ apexPluginCalls: "apexPluginCall",
511
+ assignments: "assignment",
512
+ collectionProcessors: "collectionProcessor",
513
+ customErrors: "customError",
514
+ decisions: "decision",
515
+ loops: "loop",
516
+ orchestratedStages: "orchestratedStage",
517
+ recordCreates: "recordCreate",
518
+ recordDeletes: "recordDelete",
519
+ recordLookups: "recordLookup",
520
+ recordRollbacks: "recordRollback",
521
+ recordUpdates: "recordUpdate",
522
+ screens: "screen",
523
+ steps: "step",
524
+ subflows: "subflow",
525
+ transforms: "transform",
526
+ waits: "wait",
527
+ actionCalls: "actionCall"
528
+ };
529
+ function buildModel(parsed) {
530
+ const nodes = [
531
+ toNode(parsed.start, "start"),
532
+ toNode({ name: "END", label: "End", description: "End", elementSubtype: "End", locationX: 0, locationY: 0 }, "end"),
533
+ ...collectNodes(parsed)
534
+ ].filter((node) => Boolean(node)).sort((a, b) => a.id.localeCompare(b.id));
535
+ const edges = addImplicitEndTransitions(parsed.transitions ?? [], parsed.start?.name).map(toEdge).sort((a, b) => a.id.localeCompare(b.id));
536
+ return {
537
+ flowName: parsed.fullName ?? parsed.label ?? "(unknown)",
538
+ label: parsed.label ?? parsed.fullName ?? "(unknown)",
539
+ processType: parsed.processType,
540
+ nodes,
541
+ edges
542
+ };
543
+ }
544
+ function addImplicitEndTransitions(transitions, startNodeId) {
545
+ const reachableNodeIds = /* @__PURE__ */ new Set([
546
+ ...startNodeId ? [startNodeId] : [],
547
+ ...transitions.flatMap((transition) => [transition.from, transition.to])
548
+ ]);
549
+ const nodesWithNormalExit = new Set(
550
+ transitions.filter((transition) => !transition.fault).map((transition) => transition.from)
551
+ );
552
+ const implicitEndTransitions = [...reachableNodeIds].filter((nodeId) => nodeId !== "END" && !nodesWithNormalExit.has(nodeId)).map((nodeId) => ({ from: nodeId, to: "END", fault: false }));
553
+ return [...transitions, ...implicitEndTransitions];
554
+ }
555
+ function collectNodes(parsed) {
556
+ const result = [];
557
+ for (const [collectionName, type] of Object.entries(NODE_TYPE_BY_COLLECTION)) {
558
+ const nodes = parsed[collectionName];
559
+ if (!nodes) {
560
+ continue;
561
+ }
562
+ for (const node of nodes) {
563
+ result.push(toNode(node, type));
564
+ }
565
+ }
566
+ return result;
567
+ }
568
+ function toNode(node, type) {
569
+ if (!node) {
570
+ return void 0;
571
+ }
572
+ const label = "label" in node && typeof node.label === "string" ? node.label : node.name;
573
+ return {
574
+ id: node.name,
575
+ type,
576
+ label,
577
+ properties: stripNodeProperties(structuredClone(node), true)
578
+ };
579
+ }
580
+ function toEdge(transition) {
581
+ const label = transition.label;
582
+ return {
583
+ id: `${transition.from}->${transition.to}#${transition.fault ? "fault" : "normal"}#${label ?? ""}`,
584
+ source: transition.from,
585
+ target: transition.to,
586
+ kind: transition.fault ? "fault" : "normal",
587
+ label
588
+ };
589
+ }
590
+ function stripNodeProperties(value, topLevel = false) {
591
+ if (Array.isArray(value)) {
592
+ return value.map((item) => stripNodeProperties(item));
593
+ }
594
+ if (!isPlainObject(value)) {
595
+ return value;
596
+ }
597
+ const result = {};
598
+ for (const [key, entry] of Object.entries(value)) {
599
+ if (topLevel && TOP_LEVEL_KEYS.has(key)) {
600
+ continue;
601
+ }
602
+ if (EDGE_KEYS.has(key)) {
603
+ continue;
604
+ }
605
+ if (Array.isArray(entry) && UNORDERED_ARRAY_KEYS.has(key)) {
606
+ result[key] = normalizeUnorderedArray(entry);
607
+ continue;
608
+ }
609
+ result[key] = stripNodeProperties(entry);
610
+ }
611
+ return result;
612
+ }
613
+ function normalizeUnorderedArray(items) {
614
+ return items.map((item) => stripNodeProperties(item)).sort((left, right) => stableStringify(left).localeCompare(stableStringify(right)));
615
+ }
616
+ function stableStringify(value) {
617
+ return JSON.stringify(value, (_, entry) => {
618
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
619
+ return Object.keys(entry).sort().reduce((acc, key) => {
620
+ acc[key] = entry[key];
621
+ return acc;
622
+ }, {});
623
+ }
624
+ return entry;
625
+ }) ?? "";
626
+ }
627
+ function isPlainObject(value) {
628
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
629
+ }
630
+
631
+ // src/diff/deep-diff.ts
632
+ function deepDiff(before, after, path = "") {
633
+ if (Object.is(before, after)) {
634
+ return [];
635
+ }
636
+ if (Array.isArray(before) && Array.isArray(after)) {
637
+ const changes = [];
638
+ const shared = Math.min(before.length, after.length);
639
+ for (let index = 0; index < shared; index += 1) {
640
+ changes.push(...deepDiff(before[index], after[index], joinPath(path, `[${index}]`)));
641
+ }
642
+ for (let index = shared; index < before.length; index += 1) {
643
+ changes.push({ path: joinPath(path, `[${index}]`), before: before[index], after: void 0 });
644
+ }
645
+ for (let index = shared; index < after.length; index += 1) {
646
+ changes.push({ path: joinPath(path, `[${index}]`), before: void 0, after: after[index] });
647
+ }
648
+ return changes;
649
+ }
650
+ if (isPlainObject2(before) && isPlainObject2(after)) {
651
+ const changes = [];
652
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
653
+ for (const key of [...keys].sort()) {
654
+ if (!(key in before)) {
655
+ changes.push({ path: joinPath(path, key), before: void 0, after: after[key] });
656
+ continue;
657
+ }
658
+ if (!(key in after)) {
659
+ changes.push({ path: joinPath(path, key), before: before[key], after: void 0 });
660
+ continue;
661
+ }
662
+ changes.push(...deepDiff(before[key], after[key], joinPath(path, key)));
663
+ }
664
+ return changes;
665
+ }
666
+ return [{ path, before, after }];
667
+ }
668
+ function isPlainObject2(value) {
669
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
670
+ }
671
+ function joinPath(base, segment) {
672
+ if (!base) {
673
+ return segment;
674
+ }
675
+ return segment.startsWith("[") ? `${base}${segment}` : `${base}.${segment}`;
676
+ }
677
+
678
+ // src/diff/diff-model.ts
679
+ function diffModel(oldModel, newModel) {
680
+ const oldNodes = indexById(oldModel.nodes);
681
+ const newNodes = indexById(newModel.nodes);
682
+ const oldEdges = indexById(oldModel.edges);
683
+ const newEdges = indexById(newModel.edges);
684
+ const nodeIds = [.../* @__PURE__ */ new Set([...oldNodes.keys(), ...newNodes.keys()])].sort();
685
+ const edgeIds = [.../* @__PURE__ */ new Set([...oldEdges.keys(), ...newEdges.keys()])].sort();
686
+ const nodes = nodeIds.map((id) => classifyNode(oldNodes.get(id), newNodes.get(id)));
687
+ const edges = edgeIds.map((id) => classifyEdge(oldEdges.get(id), newEdges.get(id)));
688
+ const summary = {
689
+ addedNodes: nodes.filter((node) => node.status === "added").length,
690
+ removedNodes: nodes.filter((node) => node.status === "deleted").length,
691
+ modifiedNodes: nodes.filter((node) => node.status === "modified").length,
692
+ unchangedNodes: nodes.filter((node) => node.status === "unchanged").length,
693
+ addedEdges: edges.filter((edge) => edge.status === "added").length,
694
+ removedEdges: edges.filter((edge) => edge.status === "deleted").length
695
+ };
696
+ return {
697
+ flowName: selectFlowName(oldModel, newModel),
698
+ summary,
699
+ nodes,
700
+ edges
701
+ };
702
+ }
703
+ function selectFlowName(oldModel, newModel) {
704
+ if (newModel.nodes.length > 0 || newModel.edges.length > 0) {
705
+ return newModel.flowName;
706
+ }
707
+ return oldModel.flowName;
708
+ }
709
+ function indexById(items) {
710
+ return new Map(items.map((item) => [item.id, item]));
711
+ }
712
+ function classifyNode(oldNode, newNode) {
713
+ if (!oldNode && !newNode) {
714
+ throw new Error("Cannot classify absent node");
715
+ }
716
+ if (!oldNode) {
717
+ return { id: newNode.id, type: newNode.type, label: newNode.label, status: "added" };
718
+ }
719
+ if (!newNode) {
720
+ return { id: oldNode.id, type: oldNode.type, label: oldNode.label, status: "deleted" };
721
+ }
722
+ const changes = [
723
+ ...deepDiff(oldNode.label, newNode.label, "label"),
724
+ ...deepDiff(oldNode.type, newNode.type, "type"),
725
+ ...deepDiff(oldNode.properties, newNode.properties)
726
+ ];
727
+ if (changes.length === 0) {
728
+ return {
729
+ id: newNode.id,
730
+ type: newNode.type,
731
+ label: newNode.label,
732
+ status: "unchanged"
733
+ };
734
+ }
735
+ return {
736
+ id: newNode.id,
737
+ type: newNode.type,
738
+ label: newNode.label,
739
+ status: "modified",
740
+ changes
741
+ };
742
+ }
743
+ function classifyEdge(oldEdge, newEdge) {
744
+ if (!oldEdge && !newEdge) {
745
+ throw new Error("Cannot classify absent edge");
746
+ }
747
+ if (!oldEdge) {
748
+ return toEdgeDiff(newEdge, "added");
749
+ }
750
+ if (!newEdge) {
751
+ return toEdgeDiff(oldEdge, "deleted");
752
+ }
753
+ return toEdgeDiff(newEdge, "unchanged");
754
+ }
755
+ function toEdgeDiff(edge, status) {
756
+ return {
757
+ id: edge.id,
758
+ source: edge.source,
759
+ target: edge.target,
760
+ label: edge.label,
761
+ kind: edge.kind,
762
+ status
763
+ };
764
+ }
765
+
766
+ // src/render/layout.ts
767
+ import ELK from "elkjs/lib/elk.bundled.js";
768
+ async function layoutDiff(diff) {
769
+ const elk = new ELK();
770
+ const graph = {
771
+ id: "root",
772
+ layoutOptions: {
773
+ "elk.algorithm": "layered",
774
+ "elk.direction": "DOWN",
775
+ "elk.layered.spacing.nodeNodeBetweenLayers": 60,
776
+ "elk.spacing.nodeNode": 40
777
+ },
778
+ children: diff.nodes.map((node) => ({
779
+ id: node.id,
780
+ width: estimateWidth(node.label),
781
+ height: 48
782
+ })),
783
+ edges: diff.edges.map((edge) => ({
784
+ id: edge.id,
785
+ sources: [edge.source],
786
+ targets: [edge.target]
787
+ }))
788
+ };
789
+ const laidOut = await elk.layout(graph);
790
+ const children = laidOut.children ?? [];
791
+ const edges = laidOut.edges ?? [];
792
+ const nodeMap = new Map(children.map((node) => [node.id, node]));
793
+ const edgeMap = new Map(edges.map((edge) => [edge.id, edge]));
794
+ const nodes = diff.nodes.map((node) => {
795
+ const positioned = nodeMap.get(node.id);
796
+ return {
797
+ ...node,
798
+ x: positioned?.x ?? 0,
799
+ y: positioned?.y ?? 0,
800
+ width: positioned?.width ?? estimateWidth(node.label),
801
+ height: positioned?.height ?? 48
802
+ };
803
+ });
804
+ const positionedEdges = diff.edges.map((edge) => ({
805
+ ...edge,
806
+ sections: normalizeSections(edgeMap.get(edge.id)?.sections)
807
+ }));
808
+ const bounds = measureBounds(nodes);
809
+ return {
810
+ diff,
811
+ nodes,
812
+ edges: positionedEdges,
813
+ width: bounds.width,
814
+ height: bounds.height
815
+ };
816
+ }
817
+ function estimateWidth(label) {
818
+ return Math.max(120, Math.min(320, label.length * 8 + 32));
819
+ }
820
+ function normalizeSections(sections) {
821
+ if (!sections || sections.length === 0) {
822
+ return [];
823
+ }
824
+ return sections.map((section) => ({
825
+ startPoint: { x: section.startPoint.x, y: section.startPoint.y },
826
+ endPoint: { x: section.endPoint.x, y: section.endPoint.y },
827
+ bendPoints: section.bendPoints?.map((point) => ({ x: point.x, y: point.y }))
828
+ }));
829
+ }
830
+ function measureBounds(nodes) {
831
+ const maxX = nodes.reduce((acc, node) => Math.max(acc, node.x + node.width), 0);
832
+ const maxY = nodes.reduce((acc, node) => Math.max(acc, node.y + node.height), 0);
833
+ return { width: maxX + 40, height: maxY + 40 };
834
+ }
835
+
836
+ // src/render/render-html.ts
837
+ function renderHtml(layout) {
838
+ const data = {
839
+ diff: layout.diff,
840
+ nodes: layout.nodes,
841
+ edges: layout.edges,
842
+ width: layout.width,
843
+ height: layout.height
844
+ };
845
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
846
+ const s = layout.diff.summary;
847
+ const stat = (n, kind, word) => `<span class="stat ${kind}${n === 0 ? " zero" : ""}">${n} ${word}</span>`;
848
+ const edgeStat = s.addedEdges + s.removedEdges > 0 ? `<span class="sep">\xB7</span><span class="stat edges">+${s.addedEdges}/\u2212${s.removedEdges} edges</span>` : "";
849
+ const metaHtml = `${stat(s.addedNodes, "added", "added")}<span class="sep">\xB7</span>${stat(s.removedNodes, "deleted", "deleted")}<span class="sep">\xB7</span>${stat(s.modifiedNodes, "modified", "modified")}${edgeStat}`;
850
+ const viewBox = fitViewBox(layout.width, layout.height);
851
+ return `<!doctype html>
852
+ <html lang="en">
853
+ <head>
854
+ <meta charset="utf-8" />
855
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
856
+ <title>${escapeHtml(layout.diff.flowName)}</title>
857
+ <style>
858
+ :root {
859
+ color-scheme: light;
860
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
861
+ --bg: #f5f7fb; --surface: #ffffff; --border: #e3e8ef;
862
+ --text: #1f2937; --muted: #64748b; --faint: #94a3b8;
863
+ --added: #16a34a; --added-fill: #dcfce7;
864
+ --deleted: #dc2626; --deleted-fill: #fee2e2;
865
+ --modified: #d97706; --modified-fill: #fef3c7;
866
+ --unchanged: #94a3b8; --unchanged-fill: #e9edf3;
867
+ --edge: #64748b; --fault: #7c3aed;
868
+ }
869
+ * { box-sizing: border-box; }
870
+ body { margin: 0; height: 100vh; display: grid; grid-template-rows: auto 1fr; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.45; }
871
+ header { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface); }
872
+ header h1 { margin: 0 0 2px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
873
+ header .meta { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
874
+ header .meta .stat { font-variant-numeric: tabular-nums; }
875
+ header .meta .stat.zero { color: var(--faint); }
876
+ header .meta .added:not(.zero) { color: var(--added); }
877
+ header .meta .deleted:not(.zero) { color: var(--deleted); }
878
+ header .meta .modified:not(.zero) { color: var(--modified); }
879
+ header .meta .sep { color: var(--border); }
880
+ .legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; color: var(--muted); }
881
+ .legend span::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
882
+ .legend .added::before { background: var(--added); }
883
+ .legend .deleted::before { background: var(--deleted); }
884
+ .legend .modified::before { background: var(--modified); }
885
+ .legend .unchanged::before { background: var(--unchanged); }
886
+ main { display: grid; grid-template-columns: minmax(0, 1fr) clamp(360px, 32vw, 520px); min-height: 0; }
887
+ .canvas { position: relative; overflow: hidden; }
888
+ svg {
889
+ width: 100%; height: 100%; display: block;
890
+ background:
891
+ radial-gradient(circle, #dfe5ee 1px, transparent 1.4px) -11px -11px / 22px 22px,
892
+ linear-gradient(#ffffff, #f7f9fc);
893
+ }
894
+ .panel { min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
895
+ .panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
896
+ .panel .badge { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; background: #eef1f6; color: var(--muted); margin-bottom: 16px; }
897
+ .panel.added .badge { background: var(--added-fill); color: var(--added); }
898
+ .panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
899
+ .panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
900
+ .panel .empty { color: var(--muted); font-size: 13px; }
901
+ .changes { list-style: none; padding: 0; margin: 4px 0 0; }
902
+ .changes li { min-width: 0; margin-bottom: 12px; padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: #fbfcfe; }
903
+ .change-title { font-weight: 600; margin-bottom: 5px; }
904
+ .change-path { margin-bottom: 12px; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
905
+ .change-values { display: grid; gap: 10px; min-width: 0; }
906
+ .change-value { min-width: 0; }
907
+ .change-value-label { margin-bottom: 4px; color: #475569; font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
908
+ .changes code { font-size: 12px; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; background: #eef1f6; padding: 2px 5px; border-radius: 4px; overflow-wrap: anywhere; }
909
+ .changes pre { max-width: 100%; margin: 0; padding: 11px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; }
910
+ .edge { fill: none; stroke-width: 1.75; }
911
+ .edge.normal { stroke: var(--edge); marker-end: url(#arrow-normal); }
912
+ .edge.fault { stroke: var(--fault); stroke-dasharray: 6 4; marker-end: url(#arrow-fault); }
913
+ #arrow-normal path { fill: var(--edge); }
914
+ #arrow-fault path { fill: var(--fault); }
915
+ .node rect { rx: 11; ry: 11; stroke-width: 1.75; filter: drop-shadow(0 1px 2px rgba(15, 23, 42, 0.08)); }
916
+ .node { cursor: pointer; }
917
+ .node text { font-size: 12px; fill: #111827; pointer-events: none; }
918
+ .node.added rect { fill: var(--added-fill); stroke: var(--added); }
919
+ .node.deleted rect { fill: var(--deleted-fill); stroke: var(--deleted); }
920
+ .node.modified rect { fill: var(--modified-fill); stroke: var(--modified); }
921
+ .node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
922
+ .node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px rgba(15, 23, 42, 0.18)); }
923
+ .node-label { white-space: pre; }
924
+ @media (max-width: 1000px) {
925
+ main { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
926
+ .panel { padding: 16px; }
927
+ }
928
+ </style>
929
+ </head>
930
+ <body>
931
+ <header>
932
+ <div>
933
+ <h1>${escapeHtml(layout.diff.flowName)}</h1>
934
+ <div class="meta">${metaHtml}</div>
935
+ </div>
936
+ <div class="legend">
937
+ <span class="added">Added</span>
938
+ <span class="deleted">Deleted</span>
939
+ <span class="modified">Modified</span>
940
+ <span class="unchanged">Unchanged</span>
941
+ </div>
942
+ </header>
943
+ <main>
944
+ <div class="canvas">
945
+ <svg id="flow-svg" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Flow diff">
946
+ <defs>
947
+ <marker id="arrow-normal" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
948
+ <path d="M0,0 L8,4 L0,8 Z"></path>
949
+ </marker>
950
+ <marker id="arrow-fault" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
951
+ <path d="M0,0 L8,4 L0,8 Z"></path>
952
+ </marker>
953
+ </defs>
954
+ <g id="viewport">
955
+ ${layout.edges.map(renderEdge).join("")}
956
+ ${layout.nodes.map(renderNode).join("")}
957
+ </g>
958
+ </svg>
959
+ </div>
960
+ <aside class="panel">
961
+ <h2 id="panel-title">Select a node</h2>
962
+ <div id="panel-badge" class="badge">No selection</div>
963
+ <div id="panel-body">Click a node to inspect its properties.</div>
964
+ </aside>
965
+ </main>
966
+ <script>
967
+ const DATA = ${json};
968
+ const svg = document.getElementById("flow-svg");
969
+ const viewport = document.getElementById("viewport");
970
+ const panel = document.querySelector(".panel");
971
+ const panelTitle = document.getElementById("panel-title");
972
+ const panelBadge = document.getElementById("panel-badge");
973
+ const panelBody = document.getElementById("panel-body");
974
+ const nodesById = new Map(DATA.nodes.map((node) => [node.id, node]));
975
+ let viewBox = svg.viewBox.baseVal;
976
+ let dragStart = null;
977
+
978
+ function escapeHtml(value) {
979
+ return String(value)
980
+ .replace(/&/g, "&amp;")
981
+ .replace(/</g, "&lt;")
982
+ .replace(/>/g, "&gt;")
983
+ .replace(/"/g, "&quot;");
984
+ }
985
+
986
+ function selectNode(id) {
987
+ document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
988
+ const selected = document.querySelector(\`.node[data-node-id="\${CSS.escape(id)}"]\`);
989
+ if (selected) selected.classList.add("selected");
990
+ const node = nodesById.get(id);
991
+ if (!node) return;
992
+ panel.className = "panel " + node.status;
993
+ panelTitle.textContent = node.label;
994
+ panelBadge.textContent = node.status.toUpperCase();
995
+ const changes = node.changes || [];
996
+ panelBody.innerHTML = changes.length
997
+ ? "<ul class='changes'>" + changes.map(formatChange).join("") + "</ul>"
998
+ : node.status === "added"
999
+ ? "<div class='empty'>This node was added in the new version.</div>"
1000
+ : node.status === "deleted"
1001
+ ? "<div class='empty'>This node was removed in the new version.</div>"
1002
+ : "<div class='empty'>No property changes.</div>";
1003
+ }
1004
+
1005
+ function formatChange(change) {
1006
+ const beforeMissing = change.before === undefined;
1007
+ const afterMissing = change.after === undefined;
1008
+ const action = beforeMissing ? "Added" : afterMissing ? "Removed" : "Changed";
1009
+ const values = beforeMissing
1010
+ ? formatLabeledValue("New value", change.after)
1011
+ : afterMissing
1012
+ ? formatLabeledValue("Previous value", change.before)
1013
+ : formatLabeledValue("Before", change.before) + formatLabeledValue("After", change.after);
1014
+ return "<li><div class='change-title'>" + action + ": " + escapeHtml(humanizePath(change.path)) + "</div>"
1015
+ + "<div class='change-path'>Metadata path: <code>" + escapeHtml(change.path) + "</code></div>"
1016
+ + "<div class='change-values'>" + values + "</div></li>";
1017
+ }
1018
+
1019
+ function formatLabeledValue(label, value) {
1020
+ return "<div class='change-value'><div class='change-value-label'>" + label + "</div>" + formatValue(value) + "</div>";
1021
+ }
1022
+
1023
+ function humanizePath(path) {
1024
+ const names = {
1025
+ assignmentItems: "Assignment item",
1026
+ conditions: "Condition",
1027
+ rules: "Rule",
1028
+ };
1029
+ return path.split(".").map((segment) => {
1030
+ const match = segment.match(/^([^[]+)(?:\\[(\\d+)\\])?$/);
1031
+ if (!match) return segment;
1032
+ const name = names[match[1]] || match[1].replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase());
1033
+ return match[2] === undefined ? name : name + " " + (Number(match[2]) + 1);
1034
+ }).join(" \u203A ");
1035
+ }
1036
+
1037
+ function formatValue(value) {
1038
+ if (value === undefined) return "<em>Not present</em>";
1039
+ if (value === null) return "<em>Empty value</em>";
1040
+ if (typeof value === "object") return "<pre>" + escapeHtml(JSON.stringify(value, null, 2)) + "</pre>";
1041
+ return "<code>" + escapeHtml(String(value)) + "</code>";
1042
+ }
1043
+
1044
+ document.querySelectorAll(".node").forEach((node) => {
1045
+ node.addEventListener("click", (event) => {
1046
+ event.stopPropagation();
1047
+ selectNode(node.dataset.nodeId);
1048
+ });
1049
+ });
1050
+
1051
+ svg.addEventListener("wheel", (event) => {
1052
+ event.preventDefault();
1053
+ const scale = event.deltaY < 0 ? 0.9 : 1.1;
1054
+ const rect = svg.getBoundingClientRect();
1055
+ const mx = viewBox.x + (event.clientX - rect.left) * (viewBox.width / rect.width);
1056
+ const my = viewBox.y + (event.clientY - rect.top) * (viewBox.height / rect.height);
1057
+ viewBox.x = mx - (mx - viewBox.x) * scale;
1058
+ viewBox.y = my - (my - viewBox.y) * scale;
1059
+ viewBox.width *= scale;
1060
+ viewBox.height *= scale;
1061
+ }, { passive: false });
1062
+
1063
+ svg.addEventListener("pointerdown", (event) => {
1064
+ if (event.target.closest(".node")) return;
1065
+ dragStart = { x: event.clientX, y: event.clientY, viewBox: { x: viewBox.x, y: viewBox.y } };
1066
+ svg.setPointerCapture(event.pointerId);
1067
+ });
1068
+ svg.addEventListener("pointermove", (event) => {
1069
+ if (!dragStart) return;
1070
+ const rect = svg.getBoundingClientRect();
1071
+ const dx = (event.clientX - dragStart.x) * (viewBox.width / rect.width);
1072
+ const dy = (event.clientY - dragStart.y) * (viewBox.height / rect.height);
1073
+ viewBox.x = dragStart.viewBox.x - dx;
1074
+ viewBox.y = dragStart.viewBox.y - dy;
1075
+ });
1076
+ svg.addEventListener("pointerup", () => { dragStart = null; });
1077
+ svg.addEventListener("pointercancel", () => { dragStart = null; });
1078
+
1079
+ const initialNode = DATA.nodes.find((node) => node.status === "modified")
1080
+ || DATA.nodes.find((node) => node.status !== "unchanged")
1081
+ || DATA.nodes[0];
1082
+ selectNode(initialNode?.id);
1083
+ </script>
1084
+ </body>
1085
+ </html>`;
1086
+ }
1087
+ function fitViewBox(contentWidth, contentHeight) {
1088
+ const minWidth = 720;
1089
+ const minHeight = 540;
1090
+ const width = Math.max(contentWidth, minWidth);
1091
+ const height = Math.max(contentHeight, minHeight);
1092
+ const x = (contentWidth - width) / 2;
1093
+ const y = (contentHeight - height) / 2;
1094
+ return `${round(x)} ${round(y)} ${round(width)} ${round(height)}`;
1095
+ }
1096
+ function round(value) {
1097
+ return Math.round(value * 100) / 100;
1098
+ }
1099
+ function renderEdge(edge) {
1100
+ const points = edge.sections.flatMap((section, index) => {
1101
+ const start = index === 0 ? [section.startPoint] : [];
1102
+ const bends = section.bendPoints ?? [];
1103
+ const end = [section.endPoint];
1104
+ return [...start, ...bends, ...end];
1105
+ });
1106
+ if (points.length === 0) {
1107
+ return "";
1108
+ }
1109
+ const d = points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
1110
+ return `<path class="edge ${edge.kind}" d="${d}" data-edge-id="${escapeHtml(edge.id)}"></path>`;
1111
+ }
1112
+ function renderNode(node) {
1113
+ const lines = wrapLabel(node.label, 22);
1114
+ const lineHeight = 14;
1115
+ const textY = node.height / 2 - (lines.length - 1) * lineHeight / 2 + 5;
1116
+ return `<g class="node ${node.status}" data-node-id="${escapeHtml(node.id)}" transform="translate(${node.x},${node.y})">
1117
+ <rect width="${node.width}" height="${node.height}"></rect>
1118
+ <text x="${node.width / 2}" y="${textY}" text-anchor="middle" class="node-label">
1119
+ ${lines.map((line, index) => `<tspan x="${node.width / 2}" dy="${index === 0 ? 0 : lineHeight}">${escapeHtml(line)}</tspan>`).join("")}
1120
+ </text>
1121
+ </g>`;
1122
+ }
1123
+ function wrapLabel(label, maxChars) {
1124
+ const words = label.split(/\s+/);
1125
+ const lines = [];
1126
+ let current = "";
1127
+ for (const word of words) {
1128
+ const candidate = current ? `${current} ${word}` : word;
1129
+ if (candidate.length > maxChars && current) {
1130
+ lines.push(current);
1131
+ current = word;
1132
+ } else {
1133
+ current = candidate;
1134
+ }
1135
+ }
1136
+ if (current) {
1137
+ lines.push(current);
1138
+ }
1139
+ return lines.length ? lines.slice(0, 3) : [label];
1140
+ }
1141
+ function escapeHtml(value) {
1142
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1143
+ }
1144
+
1145
+ // src/io/read-flow.ts
1146
+ import { existsSync, readFileSync } from "node:fs";
1147
+ import { execFileSync } from "node:child_process";
1148
+ function readFlowFromFile(path) {
1149
+ if (!path.endsWith(".xml")) {
1150
+ throw new Error(`Expected an XML file, got ${path}`);
1151
+ }
1152
+ if (!existsSync(path)) {
1153
+ throw new Error(`File not found: ${path}`);
1154
+ }
1155
+ return readFileSync(path, "utf8");
1156
+ }
1157
+ function readFlowFromGit(repo, ref, filePath) {
1158
+ try {
1159
+ return execFileSync("git", ["-C", repo, "show", `${ref}:${filePath}`], {
1160
+ encoding: "utf8",
1161
+ stdio: ["ignore", "pipe", "pipe"]
1162
+ });
1163
+ } catch (error) {
1164
+ const message = String(error.stderr ?? error.message ?? "");
1165
+ if (message.includes("does not exist in") || message.includes("exists on disk, but not in") || message.includes("pathspec") || message.includes("fatal: path")) {
1166
+ return null;
1167
+ }
1168
+ throw error;
1169
+ }
1170
+ }
1171
+
1172
+ // src/util/file-name.ts
1173
+ function safeFileName(value) {
1174
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
1175
+ }
1176
+
1177
+ // src/cli.ts
1178
+ var CLI_USAGE = `Usage:
1179
+ flow-delta --old <old.flow-meta.xml> --new <new.flow-meta.xml> [--out dir] [--json]
1180
+ flow-delta --from <ref> --to <ref> --repo <path> --path <glob> [--changed-only] [--out dir] [--json]
1181
+ `;
1182
+ async function main(argv = process.argv.slice(2)) {
1183
+ const parsed = parseArgs({
1184
+ args: argv,
1185
+ options: {
1186
+ old: { type: "string" },
1187
+ new: { type: "string" },
1188
+ from: { type: "string" },
1189
+ to: { type: "string" },
1190
+ repo: { type: "string" },
1191
+ path: { type: "string" },
1192
+ out: { type: "string" },
1193
+ json: { type: "boolean" },
1194
+ "changed-only": { type: "boolean" },
1195
+ help: { type: "boolean", short: "h" }
1196
+ },
1197
+ allowPositionals: false
1198
+ });
1199
+ const options = parsed.values;
1200
+ if (options.help) {
1201
+ console.log(CLI_USAGE.trimEnd());
1202
+ return;
1203
+ }
1204
+ const outDir = resolve(options.out ?? "./flow-delta-out");
1205
+ mkdirSync(outDir, { recursive: true });
1206
+ try {
1207
+ if (options.old || options.new) {
1208
+ if (!options.old || !options.new) {
1209
+ throw new Error("File mode requires both --old and --new");
1210
+ }
1211
+ await runFileMode(options.old, options.new, outDir, Boolean(options.json));
1212
+ return;
1213
+ }
1214
+ if (options.from || options.to || options.repo || options.path) {
1215
+ if (!options.from || !options.to || !options.repo || !options.path) {
1216
+ throw new Error("Git mode requires --from, --to, --repo, and --path");
1217
+ }
1218
+ await runGitMode(
1219
+ options.repo,
1220
+ options.from,
1221
+ options.to,
1222
+ options.path,
1223
+ outDir,
1224
+ Boolean(options.json),
1225
+ Boolean(options["changed-only"])
1226
+ );
1227
+ return;
1228
+ }
1229
+ throw new Error("Specify either --old/--new or --from/--to/--repo/--path");
1230
+ } catch (error) {
1231
+ console.error(error.message);
1232
+ process.exitCode = 1;
1233
+ }
1234
+ }
1235
+ async function runFileMode(oldPath, newPath, outDir, writeJson) {
1236
+ const oldModel = buildModel(await parseXml(readFlowFromFile(oldPath)));
1237
+ const newModel = buildModel(await parseXml(readFlowFromFile(newPath)));
1238
+ await writeArtifacts(oldModel, newModel, outDir, writeJson);
1239
+ }
1240
+ async function runGitMode(repo, from, to, pattern, outDir, writeJson, changedOnly) {
1241
+ const files = discoverGitFiles(repo, from, to, pattern, changedOnly);
1242
+ let hadFailure = false;
1243
+ for (const filePath of files) {
1244
+ try {
1245
+ const oldXml = readFlowFromGit(repo, from, filePath);
1246
+ const newXml = readFlowFromGit(repo, to, filePath);
1247
+ const oldModel = oldXml ? buildModel(await parseXml(oldXml)) : buildEmptyModel();
1248
+ const newModel = newXml ? buildModel(await parseXml(newXml)) : buildEmptyModel();
1249
+ await writeArtifacts(oldModel, newModel, outDir, writeJson, filePath);
1250
+ } catch (error) {
1251
+ hadFailure = true;
1252
+ console.error(`${filePath}: ${error.message}`);
1253
+ }
1254
+ }
1255
+ if (hadFailure) {
1256
+ process.exitCode = 1;
1257
+ }
1258
+ }
1259
+ async function parseXml(xml) {
1260
+ const parser = new FlowParser(xml);
1261
+ return parser.generateFlowDefinition();
1262
+ }
1263
+ function buildEmptyModel() {
1264
+ return {
1265
+ flowName: "(unknown)",
1266
+ label: "(unknown)",
1267
+ nodes: [],
1268
+ edges: []
1269
+ };
1270
+ }
1271
+ async function writeArtifacts(oldModel, newModel, outDir, writeJson, sourcePath) {
1272
+ const diff = diffModel(oldModel, newModel);
1273
+ const layout = await layoutDiff(diff);
1274
+ const html = renderHtml(layout);
1275
+ const fileStem = safeFileName(diff.flowName || sourcePath || "flow");
1276
+ writeFileSync(join(outDir, `${fileStem}.html`), html, "utf8");
1277
+ if (writeJson) {
1278
+ writeFileSync(join(outDir, `${fileStem}.diff.json`), JSON.stringify(diff, null, 2), "utf8");
1279
+ }
1280
+ console.log(
1281
+ `${diff.flowName}: nodes ${diff.summary.addedNodes} added, ${diff.summary.removedNodes} deleted, ${diff.summary.modifiedNodes} modified; edges ${diff.summary.addedEdges} added, ${diff.summary.removedEdges} deleted`
1282
+ );
1283
+ }
1284
+ function discoverGitFiles(repo, from, to, pattern, changedOnly) {
1285
+ const matcher = createPathMatcher(pattern);
1286
+ if (!changedOnly) {
1287
+ const fromFiles = listGitTree(repo, from);
1288
+ const toFiles = listGitTree(repo, to);
1289
+ const files = [.../* @__PURE__ */ new Set([...fromFiles, ...toFiles])];
1290
+ return files.filter((file) => matcher(file)).sort();
1291
+ }
1292
+ return listChangedGitFiles(repo, from, to, pattern).filter((file) => matcher(file)).sort();
1293
+ }
1294
+ function listGitTree(repo, ref) {
1295
+ return listGitFiles(repo, ["ls-tree", "-r", "--name-only", ref]);
1296
+ }
1297
+ function listChangedGitFiles(repo, from, to, pattern) {
1298
+ return listGitFiles(repo, ["diff", "--name-only", "--diff-filter=ACMRD", from, to, "--", pattern]);
1299
+ }
1300
+ function listGitFiles(repo, args) {
1301
+ return splitLines(
1302
+ execFileSync2("git", ["-C", repo, ...args], {
1303
+ encoding: "utf8",
1304
+ stdio: ["ignore", "pipe", "pipe"]
1305
+ })
1306
+ );
1307
+ }
1308
+ function splitLines(output) {
1309
+ return output.split("\n").map((line) => line.trim()).filter(Boolean);
1310
+ }
1311
+ function createPathMatcher(pattern) {
1312
+ if (!hasGlob(pattern)) {
1313
+ const normalized = pattern.replaceAll("\\", "/");
1314
+ return (path) => path === normalized;
1315
+ }
1316
+ const regex = globToRegExp(pattern.replaceAll("\\", "/"));
1317
+ return (path) => regex.test(path);
1318
+ }
1319
+ function hasGlob(value) {
1320
+ return /[*?[\]]/.test(value);
1321
+ }
1322
+ function globToRegExp(pattern) {
1323
+ let source = "^";
1324
+ for (let index = 0; index < pattern.length; index += 1) {
1325
+ const char = pattern[index];
1326
+ if (char === "*") {
1327
+ if (pattern[index + 1] === "*") {
1328
+ source += ".*";
1329
+ index += 1;
1330
+ } else {
1331
+ source += "[^/]*";
1332
+ }
1333
+ continue;
1334
+ }
1335
+ if (char === "?") {
1336
+ source += "[^/]";
1337
+ continue;
1338
+ }
1339
+ if ("\\^$+?.()|{}[]".includes(char)) {
1340
+ source += `\\${char}`;
1341
+ continue;
1342
+ }
1343
+ source += char;
1344
+ }
1345
+ source += "$";
1346
+ return new RegExp(source);
1347
+ }
1348
+ if (import.meta.url === `file://${process.argv[1]}`) {
1349
+ void main();
1350
+ }
1351
+ export {
1352
+ main
1353
+ };