@bpmnkit/core 0.0.8
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/README.md +185 -0
- package/dist/bpmn/auto-layout.d.ts +10 -0
- package/dist/bpmn/auto-layout.js +203 -0
- package/dist/bpmn/bpmn-builder.d.ts +381 -0
- package/dist/bpmn/bpmn-builder.js +1223 -0
- package/dist/bpmn/bpmn-model.d.ts +403 -0
- package/dist/bpmn/bpmn-model.js +2 -0
- package/dist/bpmn/bpmn-parser.d.ts +4 -0
- package/dist/bpmn/bpmn-parser.js +642 -0
- package/dist/bpmn/bpmn-serializer.d.ts +4 -0
- package/dist/bpmn/bpmn-serializer.js +515 -0
- package/dist/bpmn/compact.d.ts +91 -0
- package/dist/bpmn/compact.js +349 -0
- package/dist/bpmn/di-color.d.ts +9 -0
- package/dist/bpmn/di-color.js +32 -0
- package/dist/bpmn/index.d.ts +105 -0
- package/dist/bpmn/index.js +176 -0
- package/dist/bpmn/optimize/feel.d.ts +4 -0
- package/dist/bpmn/optimize/feel.js +186 -0
- package/dist/bpmn/optimize/flow.d.ts +4 -0
- package/dist/bpmn/optimize/flow.js +323 -0
- package/dist/bpmn/optimize/index.d.ts +5 -0
- package/dist/bpmn/optimize/index.js +51 -0
- package/dist/bpmn/optimize/naming.d.ts +8 -0
- package/dist/bpmn/optimize/naming.js +145 -0
- package/dist/bpmn/optimize/tasks.d.ts +4 -0
- package/dist/bpmn/optimize/tasks.js +273 -0
- package/dist/bpmn/optimize/types.d.ts +44 -0
- package/dist/bpmn/optimize/types.js +2 -0
- package/dist/bpmn/optimize/utils.d.ts +23 -0
- package/dist/bpmn/optimize/utils.js +129 -0
- package/dist/bpmn/rest-connector.d.ts +48 -0
- package/dist/bpmn/rest-connector.js +69 -0
- package/dist/bpmn/svg.d.ts +35 -0
- package/dist/bpmn/svg.js +557 -0
- package/dist/bpmn/type-guards.d.ts +136 -0
- package/dist/bpmn/type-guards.js +211 -0
- package/dist/bpmn/utils.d.ts +88 -0
- package/dist/bpmn/utils.js +207 -0
- package/dist/bpmn/zeebe-extensions.d.ts +72 -0
- package/dist/bpmn/zeebe-extensions.js +96 -0
- package/dist/dmn/compact.d.ts +44 -0
- package/dist/dmn/compact.js +125 -0
- package/dist/dmn/dmn-builder.d.ts +59 -0
- package/dist/dmn/dmn-builder.js +144 -0
- package/dist/dmn/dmn-layout.d.ts +57 -0
- package/dist/dmn/dmn-layout.js +256 -0
- package/dist/dmn/dmn-model.d.ts +158 -0
- package/dist/dmn/dmn-model.js +2 -0
- package/dist/dmn/dmn-parser.d.ts +4 -0
- package/dist/dmn/dmn-parser.js +275 -0
- package/dist/dmn/dmn-serializer.d.ts +4 -0
- package/dist/dmn/dmn-serializer.js +296 -0
- package/dist/dmn/index.d.ts +28 -0
- package/dist/dmn/index.js +51 -0
- package/dist/errors.d.ts +77 -0
- package/dist/errors.js +66 -0
- package/dist/form/compact.d.ts +20 -0
- package/dist/form/compact.js +146 -0
- package/dist/form/form-builder.d.ts +111 -0
- package/dist/form/form-builder.js +347 -0
- package/dist/form/form-model.d.ts +221 -0
- package/dist/form/form-model.js +2 -0
- package/dist/form/form-parser.d.ts +4 -0
- package/dist/form/form-parser.js +514 -0
- package/dist/form/form-serializer.d.ts +4 -0
- package/dist/form/form-serializer.js +240 -0
- package/dist/form/index.d.ts +27 -0
- package/dist/form/index.js +43 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +19 -0
- package/dist/layout/bench.d.ts +86 -0
- package/dist/layout/bench.js +225 -0
- package/dist/layout/coordinates.d.ts +62 -0
- package/dist/layout/coordinates.js +868 -0
- package/dist/layout/crossing.d.ts +8 -0
- package/dist/layout/crossing.js +60 -0
- package/dist/layout/graph.d.ts +28 -0
- package/dist/layout/graph.js +126 -0
- package/dist/layout/index.d.ts +7 -0
- package/dist/layout/index.js +5 -0
- package/dist/layout/layers.d.ts +13 -0
- package/dist/layout/layers.js +49 -0
- package/dist/layout/layout-engine.d.ts +21 -0
- package/dist/layout/layout-engine.js +191 -0
- package/dist/layout/overlap.d.ts +10 -0
- package/dist/layout/overlap.js +70 -0
- package/dist/layout/routing.d.ts +26 -0
- package/dist/layout/routing.js +439 -0
- package/dist/layout/subprocess.d.ts +14 -0
- package/dist/layout/subprocess.js +77 -0
- package/dist/layout/types.d.ts +72 -0
- package/dist/layout/types.js +37 -0
- package/dist/types/id-generator.d.ts +8 -0
- package/dist/types/id-generator.js +31 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.js +2 -0
- package/dist/types/xml-element.d.ts +12 -0
- package/dist/types/xml-element.js +2 -0
- package/dist/xml/index.d.ts +2 -0
- package/dist/xml/index.js +2 -0
- package/dist/xml/xml-parser.d.ts +13 -0
- package/dist/xml/xml-parser.js +271 -0
- package/package.json +38 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { buildFlowIndex, findProcess, readZeebeIoMapping, readZeebeTaskType } from "./utils.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// FEEL complexity scoring
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
const FEEL_KEYWORD_OPERATORS = new Set([
|
|
6
|
+
"and",
|
|
7
|
+
"or",
|
|
8
|
+
"not",
|
|
9
|
+
"if",
|
|
10
|
+
"then",
|
|
11
|
+
"else",
|
|
12
|
+
"instance",
|
|
13
|
+
"of",
|
|
14
|
+
"some",
|
|
15
|
+
"every",
|
|
16
|
+
"satisfies",
|
|
17
|
+
"function",
|
|
18
|
+
]);
|
|
19
|
+
function scoreFeelExpression(expr, opts) {
|
|
20
|
+
const length = expr.length;
|
|
21
|
+
// Nesting depth: track max depth of parentheses/brackets
|
|
22
|
+
let depth = 0;
|
|
23
|
+
let nestingDepth = 0;
|
|
24
|
+
for (const ch of expr) {
|
|
25
|
+
if (ch === "(" || ch === "[") {
|
|
26
|
+
depth++;
|
|
27
|
+
if (depth > nestingDepth)
|
|
28
|
+
nestingDepth = depth;
|
|
29
|
+
}
|
|
30
|
+
else if (ch === ")" || ch === "]") {
|
|
31
|
+
depth--;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Tokenize on non-word boundaries; collect keywords and identifiers
|
|
35
|
+
const wordTokens = expr.split(/\W+/).filter(Boolean);
|
|
36
|
+
let operatorCount = 0;
|
|
37
|
+
const variables = new Set();
|
|
38
|
+
for (const token of wordTokens) {
|
|
39
|
+
if (FEEL_KEYWORD_OPERATORS.has(token)) {
|
|
40
|
+
operatorCount++;
|
|
41
|
+
}
|
|
42
|
+
else if (!/^\d/.test(token)) {
|
|
43
|
+
variables.add(token);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Count symbol operators (order matters: longer patterns first)
|
|
47
|
+
const symbolMatches = expr.match(/!=|<=|>=|[+\-*/=<>]/g) ?? [];
|
|
48
|
+
operatorCount += symbolMatches.length;
|
|
49
|
+
const variableCount = variables.size;
|
|
50
|
+
const isComplex = length > opts.feelLengthThreshold ||
|
|
51
|
+
nestingDepth > opts.feelNestingThreshold ||
|
|
52
|
+
operatorCount > opts.feelOperatorThreshold ||
|
|
53
|
+
variableCount > opts.feelVariableThreshold;
|
|
54
|
+
return { length, nestingDepth, operatorCount, variableCount, isComplex };
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// FEEL analyzer
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
export function analyzeFeel(p, opts) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const { bySource } = buildFlowIndex(p);
|
|
62
|
+
const processId = p.id;
|
|
63
|
+
// Track FEEL expressions across all sequence flows for duplicate detection
|
|
64
|
+
const exprToFlowIds = new Map();
|
|
65
|
+
for (const flow of p.sequenceFlows) {
|
|
66
|
+
// Determine if source is an exclusive/inclusive gateway
|
|
67
|
+
const srcEl = p.flowElements.find((e) => e.id === flow.sourceRef);
|
|
68
|
+
const srcIsDecisionGateway = srcEl !== undefined &&
|
|
69
|
+
(srcEl.type === "exclusiveGateway" || srcEl.type === "inclusiveGateway");
|
|
70
|
+
if (srcIsDecisionGateway && srcEl !== undefined) {
|
|
71
|
+
const defaultFlowId = srcEl.type === "exclusiveGateway" || srcEl.type === "inclusiveGateway"
|
|
72
|
+
? srcEl.default
|
|
73
|
+
: undefined;
|
|
74
|
+
if (!flow.conditionExpression && flow.id !== defaultFlowId) {
|
|
75
|
+
findings.push({
|
|
76
|
+
id: "feel/empty-condition",
|
|
77
|
+
category: "feel",
|
|
78
|
+
severity: "error",
|
|
79
|
+
message: `Sequence flow "${flow.id}" exits a decision gateway without a condition expression.`,
|
|
80
|
+
suggestion: "Add a FEEL condition expression or mark this flow as the default.",
|
|
81
|
+
processId,
|
|
82
|
+
elementIds: [flow.id],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Track FEEL expression for duplicate detection
|
|
87
|
+
if (flow.conditionExpression) {
|
|
88
|
+
const expr = flow.conditionExpression.text.trim();
|
|
89
|
+
if (expr.length > 0) {
|
|
90
|
+
const list = exprToFlowIds.get(expr) ?? [];
|
|
91
|
+
list.push(flow.id);
|
|
92
|
+
exprToFlowIds.set(expr, list);
|
|
93
|
+
}
|
|
94
|
+
// Check complexity
|
|
95
|
+
const score = scoreFeelExpression(flow.conditionExpression.text, opts);
|
|
96
|
+
if (score.isComplex) {
|
|
97
|
+
findings.push({
|
|
98
|
+
id: "feel/complex-condition",
|
|
99
|
+
category: "feel",
|
|
100
|
+
severity: "warning",
|
|
101
|
+
message: `Condition on flow "${flow.id}" is complex (length=${score.length}, nesting=${score.nestingDepth}, operators=${score.operatorCount}, variables=${score.variableCount}).`,
|
|
102
|
+
suggestion: "Consider extracting complex FEEL expressions into named variables or decision tables.",
|
|
103
|
+
processId,
|
|
104
|
+
elementIds: [flow.id],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Check gateways: missing default flow
|
|
110
|
+
for (const el of p.flowElements) {
|
|
111
|
+
if (el.type !== "exclusiveGateway" && el.type !== "inclusiveGateway")
|
|
112
|
+
continue;
|
|
113
|
+
const outflows = bySource.get(el.id) ?? [];
|
|
114
|
+
if (outflows.length < 2)
|
|
115
|
+
continue;
|
|
116
|
+
if (!el.default) {
|
|
117
|
+
const lastFlowId = outflows[outflows.length - 1]?.id ?? "";
|
|
118
|
+
const gwId = el.id;
|
|
119
|
+
findings.push({
|
|
120
|
+
id: "feel/missing-default-flow",
|
|
121
|
+
category: "feel",
|
|
122
|
+
severity: "warning",
|
|
123
|
+
message: `Gateway "${el.id}" has ${outflows.length} outgoing flows but no default flow set.`,
|
|
124
|
+
suggestion: "Set a default flow to handle unmatched conditions.",
|
|
125
|
+
processId,
|
|
126
|
+
elementIds: [el.id],
|
|
127
|
+
applyFix: (defs) => {
|
|
128
|
+
const proc = findProcess(defs, processId);
|
|
129
|
+
if (!proc)
|
|
130
|
+
return { description: "Process not found" };
|
|
131
|
+
const gw = proc.flowElements.find((e) => e.id === gwId);
|
|
132
|
+
if (!gw)
|
|
133
|
+
return { description: "Gateway not found" };
|
|
134
|
+
if (gw.type === "exclusiveGateway" || gw.type === "inclusiveGateway") {
|
|
135
|
+
gw.default = lastFlowId;
|
|
136
|
+
}
|
|
137
|
+
return { description: `Set default flow to "${lastFlowId}" on gateway "${gwId}"` };
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Check service tasks: complex IO mapping
|
|
143
|
+
// Skip connector tasks — their ioMapping inputs are structured connector parameters
|
|
144
|
+
// (url, method, headers, body), not user-authored FEEL expressions.
|
|
145
|
+
const REST_CONNECTOR_TYPE = "io.camunda:http-json:1";
|
|
146
|
+
for (const el of p.flowElements) {
|
|
147
|
+
if (el.type !== "serviceTask")
|
|
148
|
+
continue;
|
|
149
|
+
if (readZeebeTaskType(el.extensionElements) === REST_CONNECTOR_TYPE)
|
|
150
|
+
continue;
|
|
151
|
+
const ioMapping = readZeebeIoMapping(el.extensionElements);
|
|
152
|
+
if (!ioMapping)
|
|
153
|
+
continue;
|
|
154
|
+
for (const input of ioMapping.inputs) {
|
|
155
|
+
const score = scoreFeelExpression(input.source, opts);
|
|
156
|
+
if (score.isComplex) {
|
|
157
|
+
findings.push({
|
|
158
|
+
id: "feel/complex-io-mapping",
|
|
159
|
+
category: "feel",
|
|
160
|
+
severity: "info",
|
|
161
|
+
message: `IO mapping input source on task "${el.id}" is complex.`,
|
|
162
|
+
suggestion: "Consider extracting complex FEEL expressions into intermediate variables.",
|
|
163
|
+
processId,
|
|
164
|
+
elementIds: [el.id],
|
|
165
|
+
});
|
|
166
|
+
break; // One finding per task
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Duplicate FEEL expressions
|
|
171
|
+
for (const [expr, flowIds] of exprToFlowIds) {
|
|
172
|
+
if (flowIds.length >= 2) {
|
|
173
|
+
findings.push({
|
|
174
|
+
id: "feel/duplicate-expression",
|
|
175
|
+
category: "feel",
|
|
176
|
+
severity: "info",
|
|
177
|
+
message: `FEEL expression "${expr.slice(0, 40)}" appears on ${flowIds.length} sequence flows.`,
|
|
178
|
+
suggestion: "Consider extracting repeated FEEL expressions into a shared variable or decision table.",
|
|
179
|
+
processId,
|
|
180
|
+
elementIds: flowIds,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return findings;
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=feel.js.map
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { generateId } from "../../types/id-generator.js";
|
|
2
|
+
import { buildFlowIndex, findDiPlane, findProcess, insertElement, insertFlow, isEndEvent, isGateway, isStartEvent, reachableFrom, removeElement, removeFlow, } from "./utils.js";
|
|
3
|
+
export function analyzeFlow(p, _opts) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
const processId = p.id;
|
|
6
|
+
const { bySource, byTarget } = buildFlowIndex(p);
|
|
7
|
+
// Start IDs for BFS reachability
|
|
8
|
+
const startIds = p.flowElements.filter((e) => isStartEvent(e.type)).map((e) => e.id);
|
|
9
|
+
// flow/no-start-event
|
|
10
|
+
const startEvents = p.flowElements.filter((e) => isStartEvent(e.type));
|
|
11
|
+
if (startEvents.length === 0) {
|
|
12
|
+
findings.push({
|
|
13
|
+
id: "flow/no-start-event",
|
|
14
|
+
category: "flow",
|
|
15
|
+
severity: "error",
|
|
16
|
+
message: `Process "${processId}" has no start event.`,
|
|
17
|
+
suggestion: "Add a start event to define where the process begins.",
|
|
18
|
+
processId,
|
|
19
|
+
elementIds: [],
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// flow/no-end-event
|
|
23
|
+
const endEvents = p.flowElements.filter((e) => isEndEvent(e.type));
|
|
24
|
+
if (endEvents.length === 0) {
|
|
25
|
+
findings.push({
|
|
26
|
+
id: "flow/no-end-event",
|
|
27
|
+
category: "flow",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
message: `Process "${processId}" has no end event.`,
|
|
30
|
+
suggestion: "Add an end event to properly terminate the process.",
|
|
31
|
+
processId,
|
|
32
|
+
elementIds: [],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// BFS reachability from start events
|
|
36
|
+
const reachable = reachableFrom(startIds, bySource);
|
|
37
|
+
for (const el of p.flowElements) {
|
|
38
|
+
// flow/unreachable
|
|
39
|
+
if (!reachable.has(el.id)) {
|
|
40
|
+
findings.push({
|
|
41
|
+
id: "flow/unreachable",
|
|
42
|
+
category: "flow",
|
|
43
|
+
severity: "error",
|
|
44
|
+
message: `Element "${el.id}" (${el.type}) is not reachable from any start event.`,
|
|
45
|
+
suggestion: "Connect this element to the process flow or remove it.",
|
|
46
|
+
processId,
|
|
47
|
+
elementIds: [el.id],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// flow/dead-end: non-end-event with 0 outgoing flows
|
|
51
|
+
const outflows = bySource.get(el.id) ?? [];
|
|
52
|
+
if (!isEndEvent(el.type) && outflows.length === 0) {
|
|
53
|
+
const deadEndId = el.id;
|
|
54
|
+
findings.push({
|
|
55
|
+
id: "flow/dead-end",
|
|
56
|
+
category: "flow",
|
|
57
|
+
severity: "warning",
|
|
58
|
+
message: `Element "${el.id}" (${el.type}) has no outgoing sequence flows.`,
|
|
59
|
+
suggestion: "Connect this element to a downstream element or an end event.",
|
|
60
|
+
processId,
|
|
61
|
+
elementIds: [el.id],
|
|
62
|
+
applyFix: (defs) => {
|
|
63
|
+
const proc = findProcess(defs, processId);
|
|
64
|
+
if (!proc)
|
|
65
|
+
return { description: "Process not found" };
|
|
66
|
+
const endEventId = generateId("EndEvent");
|
|
67
|
+
const flowId = generateId("Flow");
|
|
68
|
+
const endEvent = {
|
|
69
|
+
type: "endEvent",
|
|
70
|
+
id: endEventId,
|
|
71
|
+
incoming: [flowId],
|
|
72
|
+
outgoing: [],
|
|
73
|
+
extensionElements: [],
|
|
74
|
+
unknownAttributes: {},
|
|
75
|
+
eventDefinitions: [],
|
|
76
|
+
};
|
|
77
|
+
const newFlow = {
|
|
78
|
+
id: flowId,
|
|
79
|
+
sourceRef: deadEndId,
|
|
80
|
+
targetRef: endEventId,
|
|
81
|
+
extensionElements: [],
|
|
82
|
+
unknownAttributes: {},
|
|
83
|
+
};
|
|
84
|
+
// Update outgoing on dead-end element
|
|
85
|
+
const deadEl = proc.flowElements.find((e) => e.id === deadEndId);
|
|
86
|
+
if (deadEl) {
|
|
87
|
+
deadEl.outgoing.push(flowId);
|
|
88
|
+
}
|
|
89
|
+
insertElement(proc, endEvent);
|
|
90
|
+
insertFlow(proc, newFlow);
|
|
91
|
+
// Update diagram if plane exists
|
|
92
|
+
const plane = findDiPlane(defs, processId);
|
|
93
|
+
if (plane) {
|
|
94
|
+
const srcShape = plane.shapes.find((s) => s.bpmnElement === deadEndId);
|
|
95
|
+
const srcRight = srcShape ? srcShape.bounds.x + srcShape.bounds.width : 300;
|
|
96
|
+
const srcMidY = srcShape ? srcShape.bounds.y + srcShape.bounds.height / 2 : 220;
|
|
97
|
+
const endX = srcShape ? srcShape.bounds.x + srcShape.bounds.width + 150 : 450;
|
|
98
|
+
const endY = srcMidY - 18;
|
|
99
|
+
plane.shapes.push({
|
|
100
|
+
id: `${endEventId}_di`,
|
|
101
|
+
bpmnElement: endEventId,
|
|
102
|
+
bounds: { x: endX, y: endY, width: 36, height: 36 },
|
|
103
|
+
unknownAttributes: {},
|
|
104
|
+
});
|
|
105
|
+
plane.edges.push({
|
|
106
|
+
id: `${flowId}_di`,
|
|
107
|
+
bpmnElement: flowId,
|
|
108
|
+
waypoints: [
|
|
109
|
+
{ x: srcRight, y: srcMidY },
|
|
110
|
+
{ x: endX, y: srcMidY },
|
|
111
|
+
],
|
|
112
|
+
unknownAttributes: {},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return { description: `Added end event "${endEventId}" connected from "${deadEndId}"` };
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// flow/multi-incoming-task: non-gateway element with more than 1 incoming flow
|
|
120
|
+
const inflows = byTarget.get(el.id) ?? [];
|
|
121
|
+
if (!isGateway(el.type) && !isStartEvent(el.type) && inflows.length > 1) {
|
|
122
|
+
const elId = el.id;
|
|
123
|
+
const inflowCount = inflows.length;
|
|
124
|
+
findings.push({
|
|
125
|
+
id: "flow/multi-incoming-task",
|
|
126
|
+
category: "flow",
|
|
127
|
+
severity: "error",
|
|
128
|
+
message: `Element "${el.id}" (${el.type}) has ${inflows.length} incoming flows. Non-gateway elements must have at most one incoming flow.`,
|
|
129
|
+
suggestion: "Add an exclusive gateway before this element to join the incoming flows.",
|
|
130
|
+
processId,
|
|
131
|
+
elementIds: [el.id],
|
|
132
|
+
applyFix: (defs) => {
|
|
133
|
+
const proc = findProcess(defs, processId);
|
|
134
|
+
if (!proc)
|
|
135
|
+
return { description: "Process not found" };
|
|
136
|
+
const targetEl = proc.flowElements.find((e) => e.id === elId);
|
|
137
|
+
if (!targetEl)
|
|
138
|
+
return { description: "Element not found" };
|
|
139
|
+
const oldIncomingIds = [...targetEl.incoming];
|
|
140
|
+
const gatewayId = generateId("Gateway");
|
|
141
|
+
const joinFlowId = generateId("Flow");
|
|
142
|
+
// Redirect all existing incoming flows to the new gateway
|
|
143
|
+
for (const flowId of oldIncomingIds) {
|
|
144
|
+
const f = proc.sequenceFlows.find((sf) => sf.id === flowId);
|
|
145
|
+
if (f)
|
|
146
|
+
f.targetRef = gatewayId;
|
|
147
|
+
}
|
|
148
|
+
const gateway = {
|
|
149
|
+
type: "exclusiveGateway",
|
|
150
|
+
id: gatewayId,
|
|
151
|
+
incoming: oldIncomingIds,
|
|
152
|
+
outgoing: [joinFlowId],
|
|
153
|
+
extensionElements: [],
|
|
154
|
+
unknownAttributes: {},
|
|
155
|
+
};
|
|
156
|
+
const joinFlow = {
|
|
157
|
+
id: joinFlowId,
|
|
158
|
+
sourceRef: gatewayId,
|
|
159
|
+
targetRef: elId,
|
|
160
|
+
extensionElements: [],
|
|
161
|
+
unknownAttributes: {},
|
|
162
|
+
};
|
|
163
|
+
insertElement(proc, gateway);
|
|
164
|
+
insertFlow(proc, joinFlow);
|
|
165
|
+
targetEl.incoming = [joinFlowId];
|
|
166
|
+
// Add DI shape for the new gateway, positioned just before the target
|
|
167
|
+
const plane = findDiPlane(defs, processId);
|
|
168
|
+
if (plane) {
|
|
169
|
+
const tgtShape = plane.shapes.find((s) => s.bpmnElement === elId);
|
|
170
|
+
if (tgtShape) {
|
|
171
|
+
const gwWidth = 50;
|
|
172
|
+
const gwHeight = 50;
|
|
173
|
+
const gwX = tgtShape.bounds.x - 100;
|
|
174
|
+
const gwY = tgtShape.bounds.y + (tgtShape.bounds.height - gwHeight) / 2;
|
|
175
|
+
plane.shapes.push({
|
|
176
|
+
id: `${gatewayId}_di`,
|
|
177
|
+
bpmnElement: gatewayId,
|
|
178
|
+
bounds: { x: gwX, y: gwY, width: gwWidth, height: gwHeight },
|
|
179
|
+
unknownAttributes: {},
|
|
180
|
+
});
|
|
181
|
+
for (const inFlowId of oldIncomingIds) {
|
|
182
|
+
const edge = plane.edges.find((e) => e.bpmnElement === inFlowId);
|
|
183
|
+
if (edge && edge.waypoints.length > 0) {
|
|
184
|
+
const last = edge.waypoints[edge.waypoints.length - 1];
|
|
185
|
+
if (last) {
|
|
186
|
+
last.x = gwX + gwWidth / 2;
|
|
187
|
+
last.y = gwY + gwHeight / 2;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
plane.edges.push({
|
|
192
|
+
id: `${joinFlowId}_di`,
|
|
193
|
+
bpmnElement: joinFlowId,
|
|
194
|
+
waypoints: [
|
|
195
|
+
{ x: gwX + gwWidth, y: gwY + gwHeight / 2 },
|
|
196
|
+
{ x: tgtShape.bounds.x, y: tgtShape.bounds.y + tgtShape.bounds.height / 2 },
|
|
197
|
+
],
|
|
198
|
+
unknownAttributes: {},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
description: `Inserted exclusive gateway "${gatewayId}" before "${elId}" to join ${inflowCount} incoming flows`,
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// flow/redundant-gateway: gateway with exactly 1 incoming AND 1 outgoing
|
|
209
|
+
if (isGateway(el.type)) {
|
|
210
|
+
const inflows = byTarget.get(el.id) ?? [];
|
|
211
|
+
if (inflows.length === 1 && outflows.length === 1) {
|
|
212
|
+
const gwId = el.id;
|
|
213
|
+
const inFlowId = inflows[0]?.id ?? "";
|
|
214
|
+
const outFlowId = outflows[0]?.id ?? "";
|
|
215
|
+
findings.push({
|
|
216
|
+
id: "flow/redundant-gateway",
|
|
217
|
+
category: "flow",
|
|
218
|
+
severity: "info",
|
|
219
|
+
message: `Gateway "${el.id}" (${el.type}) has only 1 incoming and 1 outgoing flow — it is redundant.`,
|
|
220
|
+
suggestion: "Remove this gateway and connect its source directly to its target.",
|
|
221
|
+
processId,
|
|
222
|
+
elementIds: [el.id],
|
|
223
|
+
applyFix: (defs) => {
|
|
224
|
+
const proc = findProcess(defs, processId);
|
|
225
|
+
if (!proc)
|
|
226
|
+
return { description: "Process not found" };
|
|
227
|
+
// Find the two flows
|
|
228
|
+
const inFlow = proc.sequenceFlows.find((f) => f.id === inFlowId);
|
|
229
|
+
const outFlow = proc.sequenceFlows.find((f) => f.id === outFlowId);
|
|
230
|
+
if (!inFlow || !outFlow)
|
|
231
|
+
return { description: "Flows not found" };
|
|
232
|
+
const srcId = inFlow.sourceRef;
|
|
233
|
+
const tgtId = outFlow.targetRef;
|
|
234
|
+
const newFlowId = generateId("Flow");
|
|
235
|
+
// Remove gateway and its two flows
|
|
236
|
+
removeElement(proc, gwId);
|
|
237
|
+
removeFlow(proc, inFlowId);
|
|
238
|
+
removeFlow(proc, outFlowId);
|
|
239
|
+
// Update outgoing/incoming on connected elements
|
|
240
|
+
const srcEl = proc.flowElements.find((e) => e.id === srcId);
|
|
241
|
+
if (srcEl) {
|
|
242
|
+
srcEl.outgoing = srcEl.outgoing.filter((id) => id !== inFlowId);
|
|
243
|
+
srcEl.outgoing.push(newFlowId);
|
|
244
|
+
}
|
|
245
|
+
const tgtEl = proc.flowElements.find((e) => e.id === tgtId);
|
|
246
|
+
if (tgtEl) {
|
|
247
|
+
tgtEl.incoming = tgtEl.incoming.filter((id) => id !== outFlowId);
|
|
248
|
+
tgtEl.incoming.push(newFlowId);
|
|
249
|
+
}
|
|
250
|
+
const newFlow = {
|
|
251
|
+
id: newFlowId,
|
|
252
|
+
sourceRef: srcId,
|
|
253
|
+
targetRef: tgtId,
|
|
254
|
+
extensionElements: [],
|
|
255
|
+
unknownAttributes: {},
|
|
256
|
+
};
|
|
257
|
+
insertFlow(proc, newFlow);
|
|
258
|
+
// Update diagram
|
|
259
|
+
const plane = findDiPlane(defs, processId);
|
|
260
|
+
if (plane) {
|
|
261
|
+
plane.shapes = plane.shapes.filter((s) => s.bpmnElement !== gwId);
|
|
262
|
+
plane.edges = plane.edges.filter((e) => e.bpmnElement !== inFlowId && e.bpmnElement !== outFlowId);
|
|
263
|
+
const srcShape = plane.shapes.find((s) => s.bpmnElement === srcId);
|
|
264
|
+
const tgtShape = plane.shapes.find((s) => s.bpmnElement === tgtId);
|
|
265
|
+
if (srcShape && tgtShape) {
|
|
266
|
+
plane.edges.push({
|
|
267
|
+
id: `${newFlowId}_di`,
|
|
268
|
+
bpmnElement: newFlowId,
|
|
269
|
+
waypoints: [
|
|
270
|
+
{
|
|
271
|
+
x: srcShape.bounds.x + srcShape.bounds.width,
|
|
272
|
+
y: srcShape.bounds.y + srcShape.bounds.height / 2,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
x: tgtShape.bounds.x,
|
|
276
|
+
y: tgtShape.bounds.y + tgtShape.bounds.height / 2,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
unknownAttributes: {},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
description: `Removed redundant gateway "${gwId}" and connected "${srcId}" → "${tgtId}"`,
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// flow/mixed-gateway: gateway acting as both split AND join simultaneously
|
|
291
|
+
if (isGateway(el.type)) {
|
|
292
|
+
const gwInflows = byTarget.get(el.id) ?? [];
|
|
293
|
+
const gwOutflows = bySource.get(el.id) ?? [];
|
|
294
|
+
if (gwInflows.length >= 2 && gwOutflows.length >= 2) {
|
|
295
|
+
findings.push({
|
|
296
|
+
id: "flow/mixed-gateway",
|
|
297
|
+
category: "flow",
|
|
298
|
+
severity: "warning",
|
|
299
|
+
message: `Gateway "${el.id}" (${el.type}) has ${gwInflows.length} incoming and ${gwOutflows.length} outgoing flows — it acts as both a join and a split simultaneously.`,
|
|
300
|
+
suggestion: "Separate into two gateways: one join (merging incoming flows) followed by one split (branching outgoing flows).",
|
|
301
|
+
processId,
|
|
302
|
+
elementIds: [el.id],
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// flow/empty-subprocess
|
|
307
|
+
if (el.type === "subProcess" || el.type === "adHocSubProcess" || el.type === "transaction") {
|
|
308
|
+
if (el.flowElements.length === 0) {
|
|
309
|
+
findings.push({
|
|
310
|
+
id: "flow/empty-subprocess",
|
|
311
|
+
category: "flow",
|
|
312
|
+
severity: "warning",
|
|
313
|
+
message: `Sub-process "${el.id}" (${el.type}) contains no flow elements.`,
|
|
314
|
+
suggestion: "Add flow elements inside the sub-process or remove it.",
|
|
315
|
+
processId,
|
|
316
|
+
elementIds: [el.id],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return findings;
|
|
322
|
+
}
|
|
323
|
+
//# sourceMappingURL=flow.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BpmnDefinitions } from "../bpmn-model.js";
|
|
2
|
+
import type { OptimizationReport, OptimizeOptions } from "./types.js";
|
|
3
|
+
/** Run static analysis on a BPMN definitions object. */
|
|
4
|
+
export declare function optimize(defs: BpmnDefinitions, options?: OptimizeOptions): OptimizationReport;
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { analyzeFeel } from "./feel.js";
|
|
2
|
+
import { analyzeFlow } from "./flow.js";
|
|
3
|
+
import { analyzeNaming } from "./naming.js";
|
|
4
|
+
import { analyzeTasks } from "./tasks.js";
|
|
5
|
+
const ALL_CATEGORIES = ["feel", "flow", "naming", "task-reuse", "extract"];
|
|
6
|
+
function resolveOptions(opts) {
|
|
7
|
+
return {
|
|
8
|
+
feelLengthThreshold: opts?.feelLengthThreshold ?? 80,
|
|
9
|
+
feelNestingThreshold: opts?.feelNestingThreshold ?? 3,
|
|
10
|
+
feelOperatorThreshold: opts?.feelOperatorThreshold ?? 5,
|
|
11
|
+
feelVariableThreshold: opts?.feelVariableThreshold ?? 4,
|
|
12
|
+
reuseThreshold: opts?.reuseThreshold ?? 2,
|
|
13
|
+
categories: opts?.categories ?? [...ALL_CATEGORIES],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Run static analysis on a BPMN definitions object. */
|
|
17
|
+
export function optimize(defs, options) {
|
|
18
|
+
const resolved = resolveOptions(options);
|
|
19
|
+
const findings = [];
|
|
20
|
+
for (const process of defs.processes) {
|
|
21
|
+
if (resolved.categories.includes("feel")) {
|
|
22
|
+
findings.push(...analyzeFeel(process, resolved));
|
|
23
|
+
}
|
|
24
|
+
if (resolved.categories.includes("flow")) {
|
|
25
|
+
findings.push(...analyzeFlow(process, resolved));
|
|
26
|
+
}
|
|
27
|
+
if (resolved.categories.includes("naming")) {
|
|
28
|
+
findings.push(...analyzeNaming(process, resolved));
|
|
29
|
+
}
|
|
30
|
+
if (resolved.categories.includes("task-reuse")) {
|
|
31
|
+
findings.push(...analyzeTasks(process, resolved));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const byCategory = Object.fromEntries(["feel", "flow", "naming", "task-reuse", "extract"].map((c) => [
|
|
35
|
+
c,
|
|
36
|
+
findings.filter((f) => f.category === c).length,
|
|
37
|
+
]));
|
|
38
|
+
const bySeverity = Object.fromEntries(["info", "warning", "error"].map((s) => [
|
|
39
|
+
s,
|
|
40
|
+
findings.filter((f) => f.severity === s).length,
|
|
41
|
+
]));
|
|
42
|
+
return {
|
|
43
|
+
findings,
|
|
44
|
+
summary: {
|
|
45
|
+
total: findings.length,
|
|
46
|
+
byCategory,
|
|
47
|
+
bySeverity,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BpmnProcess } from "../bpmn-model.js";
|
|
2
|
+
import type { OptimizationFinding, ResolvedOptions } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Analyze BPMN naming conventions based on Camunda best practices:
|
|
5
|
+
* https://docs.camunda.io/docs/components/best-practices/modeling/naming-bpmnkit-elements/
|
|
6
|
+
*/
|
|
7
|
+
export declare function analyzeNaming(p: BpmnProcess, _opts: ResolvedOptions): OptimizationFinding[];
|
|
8
|
+
//# sourceMappingURL=naming.d.ts.map
|