bpmnlint-plugin-camunda-compat 2.21.0 → 2.22.0

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/index.js CHANGED
@@ -12,6 +12,7 @@ const camundaCloud10Rules = withConfig({
12
12
  'loop-characteristics': 'error',
13
13
  'message-reference': 'error',
14
14
  'no-candidate-users': 'error',
15
+ 'no-execution-listeners': 'error',
15
16
  'no-expression': 'error',
16
17
  'no-loop': 'error',
17
18
  'no-multiple-none-start-events': 'error',
@@ -75,8 +76,11 @@ const camundaCloud85Rules = withConfig({
75
76
  'wait-for-completion': 'error'
76
77
  }, { version: '8.5' });
77
78
 
78
- const camundaCloud86Rules = withConfig(
79
- omit(camundaCloud85Rules, 'inclusive-gateway'), { version: '8.6' });
79
+ const camundaCloud86Rules = withConfig({
80
+ ...omit(camundaCloud85Rules, [ 'inclusive-gateway', 'no-execution-listeners' ]),
81
+ 'duplicate-execution-listeners': 'error',
82
+ 'execution-listener': 'error'
83
+ }, { version: '8.6' });
80
84
 
81
85
  const camundaPlatform719Rules = withConfig({
82
86
  'history-time-to-live': 'info'
@@ -105,12 +109,14 @@ const rules = {
105
109
  'called-element': './rules/camunda-cloud/called-element',
106
110
  'collapsed-subprocess': './rules/camunda-cloud/collapsed-subprocess',
107
111
  'connector-properties': './rules/camunda-cloud/connector-properties',
112
+ 'duplicate-execution-listeners': './rules/camunda-cloud/duplicate-execution-listeners',
108
113
  'duplicate-task-headers': './rules/camunda-cloud/duplicate-task-headers',
109
114
  'error-reference': './rules/camunda-cloud/error-reference',
110
115
  'escalation-boundary-event-attached-to-ref': './rules/camunda-cloud/escalation-boundary-event-attached-to-ref',
111
116
  'escalation-reference': './rules/camunda-cloud/escalation-reference',
112
117
  'event-based-gateway-target': './rules/camunda-cloud/event-based-gateway-target',
113
118
  'executable-process': './rules/camunda-cloud/executable-process',
119
+ 'execution-listener': './rules/camunda-cloud/execution-listener',
114
120
  'feel': './rules/camunda-cloud/feel',
115
121
  'history-time-to-live': './rules/camunda-platform/history-time-to-live',
116
122
  'implementation': './rules/camunda-cloud/implementation',
@@ -119,6 +125,7 @@ const rules = {
119
125
  'loop-characteristics': './rules/camunda-cloud/loop-characteristics',
120
126
  'message-reference': './rules/camunda-cloud/message-reference',
121
127
  'no-candidate-users': './rules/camunda-cloud/no-candidate-users',
128
+ 'no-execution-listeners': './rules/camunda-cloud/no-execution-listeners',
122
129
  'no-expression': './rules/camunda-cloud/no-expression',
123
130
  'no-loop': './rules/camunda-cloud/no-loop',
124
131
  'no-multiple-none-start-events': './rules/camunda-cloud/no-multiple-none-start-events',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bpmnlint-plugin-camunda-compat",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "A bpmnlint plug-in for Camunda compatibility",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -34,7 +34,7 @@
34
34
  "modeler-moddle": "^0.2.0",
35
35
  "sinon": "^17.0.1",
36
36
  "sinon-chai": "^3.7.0",
37
- "zeebe-bpmn-moddle": "^1.1.0"
37
+ "zeebe-bpmn-moddle": "^1.4.0"
38
38
  },
39
39
  "dependencies": {
40
40
  "@bpmn-io/feel-lint": "^1.2.0",
@@ -0,0 +1,33 @@
1
+ const {
2
+ findExtensionElement,
3
+ hasDuplicatedPropertiesValues
4
+ } = require('../utils/element');
5
+
6
+ const { reportErrors } = require('../utils/reporter');
7
+
8
+ const { skipInNonExecutableProcess } = require('../utils/rule');
9
+
10
+ module.exports = skipInNonExecutableProcess(function() {
11
+ function check(node, reporter) {
12
+ const executionListeners = findExtensionElement(node, 'zeebe:ExecutionListeners');
13
+
14
+ if (!executionListeners) {
15
+ return;
16
+ }
17
+
18
+ const errors = hasDuplicatedExecutionListeners(executionListeners, node);
19
+
20
+ if (errors && errors.length) {
21
+ reportErrors(node, reporter, errors);
22
+ }
23
+ }
24
+
25
+ return {
26
+ check
27
+ };
28
+ });
29
+
30
+ // helpers //////////
31
+ function hasDuplicatedExecutionListeners(executionListeners, parentNode = null) {
32
+ return hasDuplicatedPropertiesValues(executionListeners, 'listeners', [ 'eventType', 'type' ], parentNode);
33
+ }
@@ -0,0 +1,34 @@
1
+ const {
2
+ findExtensionElement,
3
+ hasProperties
4
+ } = require('../utils/element');
5
+
6
+ const { reportErrors } = require('../utils/reporter');
7
+
8
+ const { skipInNonExecutableProcess } = require('../utils/rule');
9
+
10
+
11
+ module.exports = skipInNonExecutableProcess(function() {
12
+ function check(node, reporter) {
13
+ const executionListeners = findExtensionElement(node, 'zeebe:ExecutionListeners');
14
+
15
+ if (!executionListeners) {
16
+ return;
17
+ }
18
+
19
+ const listeners = executionListeners.get('listeners');
20
+ const errors = listeners.flatMap(listener => hasProperties(listener, {
21
+ type: {
22
+ required: true
23
+ }
24
+ }, node));
25
+
26
+ if (errors.length) {
27
+ reportErrors(node, reporter, errors);
28
+ }
29
+ }
30
+
31
+ return {
32
+ check
33
+ };
34
+ });
@@ -0,0 +1,21 @@
1
+ const { reportErrors } = require('../utils/reporter');
2
+
3
+ const { skipInNonExecutableProcess } = require('../utils/rule');
4
+
5
+ const { hasNoExtensionElement } = require('../utils/element');
6
+
7
+ const ALLOWED_VERSION = '8.6';
8
+
9
+ module.exports = skipInNonExecutableProcess(function() {
10
+ function check(node, reporter) {
11
+ const errors = hasNoExtensionElement(node, 'zeebe:ExecutionListeners', node, ALLOWED_VERSION);
12
+
13
+ if (errors && errors.length) {
14
+ reportErrors(node, reporter, errors);
15
+ }
16
+ }
17
+
18
+ return {
19
+ check
20
+ };
21
+ });
@@ -14,6 +14,10 @@ const { ERROR_TYPES } = require('../utils/error-types');
14
14
 
15
15
  const { skipInNonExecutableProcess } = require('../utils/rule');
16
16
 
17
+ /**
18
+ * @typedef {import('bpmn-moddle').BaseElement} ModdleElement
19
+ **/
20
+
17
21
  const LOOP_REQUIRED_ELEMENT_TYPES = [
18
22
  'bpmn:CallActivity',
19
23
  'bpmn:ManualTask',
@@ -38,16 +42,22 @@ module.exports = skipInNonExecutableProcess(function() {
38
42
  return;
39
43
  }
40
44
 
41
- const error = getFlowElements(node)
45
+ // 1. Remove all elements that can be part of an infinite loop
46
+ const relevantNodes = getFlowElements(node)
42
47
  .filter(flowElement => {
43
48
  return isAnyExactly(flowElement, LOOP_ELEMENT_TYPES);
44
- })
45
- .reduce((error, flowElement) => {
46
- return error || findLoop(flowElement, node);
47
- }, null);
49
+ });
50
+
51
+ // 2. Remove all non-required elements. This produces a graph that only contains the required elements,
52
+ // with annotated edges that preserve the original path.
53
+ // Any loop found within the simplified graph is a valid loop, as all Vertices in the graph are `LOOP_REQUIRED_ELEMENT_TYPES`.
54
+ const minimalGraph = simplifyGraph(relevantNodes);
48
55
 
49
- if (error) {
50
- reportErrors(node, reporter, error);
56
+ // 3. Use breadth-first search to find loops in the simplified Graph.
57
+ const errors = findLoops(minimalGraph, node);
58
+
59
+ if (errors) {
60
+ reportErrors(node, reporter, errors);
51
61
  }
52
62
  }
53
63
 
@@ -56,51 +66,112 @@ module.exports = skipInNonExecutableProcess(function() {
56
66
  };
57
67
  });
58
68
 
59
- function findLoop(flowElement, parentElement, visitedFlowElements = []) {
69
+ /**
70
+ * @typedef {Object} GraphNode
71
+ * @property {ModdleElement} element the bpmn element this node represents
72
+ * @property {Map<ModdleElement, Array<ModdleElement>>} incoming Maps the target node with the shortest path to it
73
+ * @property {Map<ModdleElement, Array<ModdleElement>>} outgoing Maps the source node with the shortest path to it
74
+ */
60
75
 
61
- // (1.1) is not a loop
62
- if (!isAnyExactly(flowElement, LOOP_ELEMENT_TYPES)) {
63
- return null;
64
- }
76
+ /**
77
+ * Simplifies the graph by removing all non-`LOOP_REQUIRED_ELEMENT_TYPES` elements and connecting incoming and outgoing nodes directly.
78
+ * Annotates the edges with the original path. Uses breadth-first search to find paths.
79
+ *
80
+ * @param {Array<ModdleElement>} flowElements
81
+ * @returns {Map<ModdleElement, GraphNode>}
82
+ */
83
+ function simplifyGraph(flowElements) {
65
84
 
66
- const nextFlowElements = getNextFlowElements(flowElement);
85
+ // Transform Array<ModdleElement> into Map<ModdleElement, GraphNode>
86
+ const graph = elementsToGraph(flowElements);
67
87
 
68
- // (1.2) is not a loop
69
- if (!nextFlowElements.length) {
70
- return null;
71
- }
88
+ breadthFirstSearch(graph, (node) => {
89
+ const { element, outgoing } = node;
72
90
 
73
- // (2) may be a loop
74
- if (!visitedFlowElements.includes(flowElement)) {
75
- return nextFlowElements.reduce((error, nextFlowElement) => {
76
- return error || findLoop(nextFlowElement, parentElement, [ ...visitedFlowElements, flowElement ]);
77
- }, null);
78
- }
91
+ // Remove non-required element and connect incoming and outgoing nodes directly
92
+ if (!isAnyExactly(element, LOOP_REQUIRED_ELEMENT_TYPES)) {
93
+ connectNodes(graph, node);
94
+ }
95
+
96
+ return Array.from(outgoing.keys(), key => graph.get(key));
97
+ });
98
+
99
+ // Clean up all references to removed elements
100
+ graph.forEach(({ incoming, outgoing }) => {
101
+ incoming.forEach((_, key) => {
102
+ if (!graph.has(key)) {
103
+ incoming.delete(key);
104
+ }
105
+ });
106
+
107
+ outgoing.forEach((_, key) => {
108
+ if (!graph.has(key)) {
109
+ outgoing.delete(key);
110
+ }
111
+ });
112
+ });
79
113
 
80
- // (3) is a loop but ignored
81
- if (isIgnoredLoop(visitedFlowElements)) {
114
+ return graph;
115
+ }
116
+
117
+
118
+ /**
119
+ * Uses breadth-first search to find loops in the graph and generate errors.
120
+ *
121
+ * @param {Map<ModdleElement, GraphNode>} graph The simplified graph containing only required elements
122
+ * @param {ModdleElement} root used for reporting the errors
123
+ * @returns {Array<Object>} errors
124
+ */
125
+ function findLoops(graph, root) {
126
+ const errors = [];
127
+
128
+ // Traverse graph using breadth-first search, remembering the path. If we find a loop, report it.
129
+ breadthFirstSearch(graph, (node) => {
130
+ const { element, outgoing, path = [] } = node;
131
+
132
+ const nextElements = [ ];
133
+ outgoing.forEach((connectionPath, nextElement) => {
134
+ const newPath = [ ...path, element, ...connectionPath ];
135
+
136
+ // We already visited this node, we found a loop
137
+ if (newPath.includes(nextElement)) {
138
+ errors.push(handleLoop(newPath, nextElement, root));
139
+ } else {
140
+ const nextNode = graph.get(nextElement);
141
+ nextNode.path = nextNode.path || newPath;
142
+ nextElements.push(nextNode);
143
+ }
144
+ });
145
+
146
+ return nextElements;
147
+ });
148
+
149
+ return errors.filter(Boolean);
150
+ }
151
+
152
+ const handleLoop = (path, currentNode, root) => {
153
+ const loop = path.slice(path.indexOf(currentNode));
154
+
155
+ if (isIgnoredLoop(loop)) {
82
156
  return null;
83
157
  }
84
158
 
85
- const elements = visitedFlowElements.slice(visitedFlowElements.indexOf(flowElement));
86
-
87
- // (4) is a loop
88
159
  return {
89
- message: `Loop detected: ${ elements.map(({ id }) => id).join(' -> ') } -> ${ flowElement.id }`,
160
+ message: `Loop detected: ${ loop.map(({ id }) => id).join(' -> ') } -> ${ currentNode.id }`,
90
161
  path: null,
91
162
  data: {
92
163
  type: ERROR_TYPES.LOOP_NOT_ALLOWED,
93
- node: parentElement,
164
+ node: root,
94
165
  parentNode: null,
95
- elements: elements.map(({ id }) => id)
166
+ elements: loop.map(({ id }) => id)
96
167
  }
97
168
  };
98
- }
169
+ };
99
170
 
100
171
  function getFlowElements(node) {
101
172
  return node.get('flowElements').reduce((flowElements, flowElement) => {
102
173
  if (is(flowElement, 'bpmn:FlowElementsContainer')) {
103
- return [ ...flowElements, ...getFlowElements(flowElement) ];
174
+ return [ ...flowElements, flowElement, ...getFlowElements(flowElement) ];
104
175
  }
105
176
 
106
177
  return [ ...flowElements, flowElement ];
@@ -143,4 +214,104 @@ function isIgnoredLoop(elements) {
143
214
 
144
215
  function isFeel(value) {
145
216
  return isString(value) && value.startsWith('=');
217
+ }
218
+
219
+ const getOrSet = (map, key, defaultValue) => {
220
+ if (!map.has(key)) {
221
+ map.set(key, defaultValue);
222
+ }
223
+
224
+ return map.get(key);
225
+ };
226
+
227
+ const setIfAbsent = (map, key, value) => {
228
+ map.has(key) || map.set(key, value);
229
+ };
230
+
231
+ /**
232
+ * Transform Array of flow elements into a Graph structure, adding implicit connections (e.g. SubProcess -> StartEvent)
233
+ * via `getNextFlowElements`.
234
+ *
235
+ * @param {Array<ModdleElement>} flowElements
236
+ * @returns Map<ModdleElement, GraphNode>
237
+ */
238
+ function elementsToGraph(flowElements) {
239
+ return flowElements.reduce((currentMap, element) => {
240
+ const currentNode = getOrSet(currentMap, element, {
241
+ element,
242
+ incoming: new Map(),
243
+ outgoing: new Map(),
244
+ });
245
+
246
+ const nextFlowElements = getNextFlowElements(element);
247
+
248
+ nextFlowElements.forEach(nextElement => {
249
+ const nextNode = getOrSet(currentMap, nextElement, {
250
+ element: nextElement,
251
+ incoming: new Map(),
252
+ outgoing: new Map(),
253
+ });
254
+
255
+ nextNode.incoming.set(element, []);
256
+ currentNode.outgoing.set(nextElement, []);
257
+ });
258
+
259
+ return currentMap;
260
+ }, new Map());
261
+ }
262
+
263
+ /**
264
+ * Connects incoming and outgoing nodes directly, add current node to the path and remove node from graph.
265
+ */
266
+ function connectNodes(graph, node) {
267
+ const { element, incoming, outgoing } = node;
268
+
269
+ incoming.forEach((fromPath, fromKey) => {
270
+ outgoing.forEach((toPath, toKey) => {
271
+ const fromNode = graph.get(fromKey);
272
+ const toNode = graph.get(toKey);
273
+
274
+ if (!fromNode || !toNode) {
275
+ return;
276
+ }
277
+
278
+ // We only care about the shortest path, so we don't need to update the path if it's already set
279
+ setIfAbsent(fromNode.outgoing, toKey, [ ...fromPath, element, ...toPath ]);
280
+ setIfAbsent(toNode.incoming, fromKey, [ ...fromPath, element, ...toPath ]);
281
+ });
282
+ });
283
+
284
+ graph.delete(element);
285
+ }
286
+
287
+ /**
288
+ * Iterates over all nodes in the graph using breadth-first search.
289
+ *
290
+ * @param {Map<ModdleElement, GraphNode>} graph
291
+ * @param {Function} iterationCallback
292
+ */
293
+ function breadthFirstSearch(graph, iterationCallback) {
294
+ const unvisited = new Set(graph.values());
295
+
296
+ while (unvisited.size) {
297
+ let firstElement = unvisited.values().next().value;
298
+ unvisited.delete(firstElement);
299
+
300
+ const elementsToVisit = [ firstElement ];
301
+
302
+ while (elementsToVisit.length) {
303
+ const node = elementsToVisit.shift();
304
+
305
+ const nextElements = iterationCallback(node);
306
+
307
+ nextElements.forEach(nextElement => {
308
+ if (!unvisited.has(nextElement)) {
309
+ return;
310
+ }
311
+
312
+ unvisited.delete(nextElement);
313
+ elementsToVisit.push(nextElement);
314
+ });
315
+ }
316
+ }
146
317
  }
@@ -1,4 +1,5 @@
1
1
  const {
2
+ filter,
2
3
  isArray,
3
4
  isDefined,
4
5
  isFunction,
@@ -6,6 +7,7 @@ const {
6
7
  isObject,
7
8
  isString,
8
9
  isUndefined,
10
+ matchPattern,
9
11
  some
10
12
  } = require('min-dash');
11
13
 
@@ -133,6 +135,53 @@ module.exports.hasDuplicatedPropertyValues = function(node, propertiesName, prop
133
135
  return [];
134
136
  };
135
137
 
138
+ // @TODO(@barmac): use tree algorithm to reduce complexity
139
+ module.exports.hasDuplicatedPropertiesValues = function(node, containerPropertyName, propertiesNames, parentNode = null) {
140
+ const properties = node.get(containerPropertyName);
141
+
142
+ // (1) find duplicates
143
+ const duplicates = properties.reduce((foundDuplicates, property, index) => {
144
+ const previous = properties.slice(0, index);
145
+ const isDuplicate = previous.find(p => propertiesNames.every(propertyName => p.get(propertyName) === property.get(propertyName)));
146
+
147
+ if (isDuplicate) {
148
+ return foundDuplicates.concat(property);
149
+ }
150
+
151
+ return foundDuplicates;
152
+ }, []);
153
+
154
+ // (2) report error for each duplicate
155
+ if (duplicates.length) {
156
+ return duplicates.map(duplicate => {
157
+ const propertiesMap = {};
158
+ for (const property of propertiesNames) {
159
+ propertiesMap[property] = duplicate.get(property);
160
+ }
161
+
162
+ // (3) find properties with duplicate
163
+ const duplicateProperties = filter(properties, matchPattern(propertiesMap));
164
+ const duplicatesSummary = propertiesNames.map(propertyName => `property <${ propertyName }> with duplicate value of <${ propertiesMap[propertyName] }>`).join(', ');
165
+
166
+ // (4) report error
167
+ return {
168
+ message: `Properties of type <${ duplicate.$type }> have properties with duplicate values (${ duplicatesSummary })`,
169
+ path: null,
170
+ data: {
171
+ type: ERROR_TYPES.PROPERTY_VALUES_DUPLICATED,
172
+ node,
173
+ parentNode: parentNode == node ? null : parentNode,
174
+ duplicatedProperties: propertiesMap,
175
+ properties: duplicateProperties,
176
+ propertiesName: containerPropertyName
177
+ }
178
+ };
179
+ });
180
+ }
181
+
182
+ return [];
183
+ };
184
+
136
185
  module.exports.hasProperties = function(node, properties, parentNode = null) {
137
186
  return Object.entries(properties).reduce((results, property) => {
138
187
  const [ propertyName, propertyChecks ] = property;
@@ -19,6 +19,7 @@ module.exports.ERROR_TYPES = Object.freeze({
19
19
  PROPERTY_REQUIRED: 'camunda.propertyRequired',
20
20
  PROPERTY_TYPE_NOT_ALLOWED: 'camunda.propertyTypeNotAllowed',
21
21
  PROPERTY_VALUE_DUPLICATED: 'camunda.propertyValueDuplicated',
22
+ PROPERTY_VALUES_DUPLICATED: 'camunda.propertiesValuesDuplicated',
22
23
  PROPERTY_VALUE_NOT_ALLOWED: 'camunda.propertyValueNotAllowed',
23
24
  PROPERTY_VALUE_REQUIRED: 'camunda.propertyValueRequired',
24
25
  SECRET_EXPRESSION_FORMAT_DEPRECATED: 'camunda.secretExpressionFormatDeprecated'