bpmnlint-plugin-camunda-compat 2.58.2 → 2.59.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/README.md CHANGED
@@ -79,7 +79,8 @@ npx bpmnlint my-diagram.bpmn
79
79
  ## Resources
80
80
 
81
81
  * [Issues](https://github.com/camunda/bpmnlint-plugin-camunda-compat/issues)
82
-
82
+ * [Architecture documentation](./docs/ARCHITECTURE.md)
83
+ * [Rule authoring guide](./docs/RULE_AUTHORING.md)
83
84
 
84
85
  ## Related
85
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bpmnlint-plugin-camunda-compat",
3
- "version": "2.58.2",
3
+ "version": "2.59.0",
4
4
  "description": "A bpmnlint plug-in for Camunda compatibility",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1,5 +1,7 @@
1
1
  const { is } = require('bpmnlint-utils');
2
2
 
3
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
4
+
3
5
  const { findExtensionElement, findAncestorAdHocSubProcess, isAgenticAdHocSubProcess } = require('../utils/element');
4
6
  const { CORRECT_NAME, NAME_ALIASES, findFunctionInvocations, getPositionalArgs } = require('./utils/feel');
5
7
  const { reportErrors } = require('../utils/reporter');
@@ -156,12 +158,11 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
156
158
  return;
157
159
  }
158
160
 
159
- // Properties-panel entry for this input parameter, so clicking the report
160
- // opens the right mapping (id convention: {elementId}-input-{index}-source).
161
- const inputIndex = (node.$parent.get('inputParameters') || []).indexOf(node);
162
- const propertiesPanel = {
163
- entryIds: [ `${ task.get('id') }-input-${ inputIndex }-source` ]
164
- };
161
+ // Moddle path to this input parameter's source, so a single downstream
162
+ // resolver maps it to the entry the modeler actually renders — the standard
163
+ // input mapping, or a template's custom entry. The rule stays agnostic to
164
+ // the id scheme.
165
+ const path = pathConcat(getPath(node, task), 'source');
165
166
 
166
167
  const ahsp = findAncestorAdHocSubProcess(task);
167
168
 
@@ -169,7 +170,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
169
170
  reportErrors(task, reporter, invocations.map(() => ({
170
171
  message: 'fromAi() should only be used inside an agentic sub-process.',
171
172
  data: { type: ERROR_TYPES.AGENT_FEEL_WRONG_CONTEXT },
172
- propertiesPanel,
173
+ path,
173
174
  })));
174
175
  return;
175
176
  }
@@ -179,7 +180,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
179
180
  reportErrors(task, reporter, invocations.map(() => ({
180
181
  message: `The "${ahspLabel}" sub-process is not marked as agentic, so fromAi() has no effect.`,
181
182
  data: { type: ERROR_TYPES.AGENT_FEEL_WRONG_CONTEXT },
182
- propertiesPanel,
183
+ path,
183
184
  })));
184
185
  return;
185
186
  }
@@ -194,7 +195,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
194
195
  reportErrors(task, reporter, invocations.map(() => ({
195
196
  message: 'fromAi() is ignored here: only the tool\'s entry element defines AI inputs. Define it there and read the toolCall variable directly.',
196
197
  data: { type: ERROR_TYPES.AGENT_FEEL_NON_ENTRY_ELEMENT },
197
- propertiesPanel,
198
+ path,
198
199
  })));
199
200
  return;
200
201
  }
@@ -237,7 +238,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
237
238
  }
238
239
 
239
240
  if (errors.length) {
240
- reportErrors(task, reporter, errors.map(error => ({ ...error, propertiesPanel })));
241
+ reportErrors(task, reporter, errors.map(error => ({ ...error, path })));
241
242
  }
242
243
  }
243
244
 
@@ -273,7 +274,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
273
274
  return;
274
275
  }
275
276
 
276
- const keyCounts = {};
277
+ const keyOccurrences = {};
277
278
 
278
279
  for (const input of ioMapping.get('inputParameters') || []) {
279
280
  const source = input.get('source');
@@ -286,18 +287,18 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
286
287
  const args = getPositionalArgs(inv.node, expr);
287
288
  const key = args[ 0 ];
288
289
  if (key && key.type === 'PathExpression' && key.text.startsWith('toolCall.')) {
289
- keyCounts[ key.text ] = (keyCounts[ key.text ] || 0) + 1;
290
+ (keyOccurrences[ key.text ] = keyOccurrences[ key.text ] || []).push(input);
290
291
  }
291
292
  }
292
293
  }
293
294
 
294
- const duplicates = Object.keys(keyCounts).filter(key => keyCounts[ key ] > 1);
295
+ const duplicates = Object.keys(keyOccurrences).filter(key => keyOccurrences[ key ].length > 1);
295
296
 
296
297
  if (duplicates.length) {
297
298
  reportErrors(task, reporter, duplicates.map(key => ({
298
299
  message: `fromAi() key ${ key } is declared more than once in this tool. Declare it once and reference it directly elsewhere.`,
299
300
  data: { type: ERROR_TYPES.AGENT_FEEL_KEY_DUPLICATE },
300
- propertiesPanel: { entryIds: [ 'inputs' ] },
301
+ paths: keyOccurrences[ key ].map(input => pathConcat(getPath(input, task), 'source')),
301
302
  })));
302
303
  }
303
304
  }
@@ -330,15 +331,12 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
330
331
  return;
331
332
  }
332
333
 
333
- const outputIndex = (node.$parent.get('outputParameters') || []).indexOf(node);
334
- const propertiesPanel = {
335
- entryIds: [ `${ task.get('id') }-output-${ outputIndex }-source` ]
336
- };
334
+ const path = pathConcat(getPath(node, task), 'source');
337
335
 
338
336
  reportErrors(task, reporter, invocations.map(() => ({
339
337
  message: 'fromAi() defines a tool input and has no effect in an output mapping. Define it in an input mapping on the tool\'s entry element.',
340
338
  data: { type: ERROR_TYPES.AGENT_FEEL_WRONG_CONTEXT },
341
- propertiesPanel,
339
+ path,
342
340
  })));
343
341
  }
344
342
 
@@ -368,7 +366,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
368
366
  reportErrors(node, reporter, invocations.map(() => ({
369
367
  message: 'fromAi() defines a tool input and cannot be used in a sequence flow condition. Define it in an input mapping on the tool\'s entry element.',
370
368
  data: { type: ERROR_TYPES.AGENT_FEEL_WRONG_CONTEXT },
371
- propertiesPanel: { entryIds: [ 'conditionExpression' ] },
369
+ path: getPath(condition, node),
372
370
  })));
373
371
  }
374
372
  });
@@ -29,7 +29,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
29
29
  {
30
30
  message: 'Tool documentation is missing.',
31
31
  data: { type: ERROR_TYPES.AGENT_TOOL_DOCUMENTATION_MISSING },
32
- propertiesPanel: { entryIds: [ 'documentation' ] },
32
+ path: [ 'documentation' ],
33
33
  },
34
34
  ]);
35
35
  }
@@ -1,5 +1,7 @@
1
1
  const { is } = require('bpmnlint-utils');
2
2
 
3
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
4
+
3
5
  const { isAgenticToolElement } = require('../utils/element');
4
6
  const { reportErrors, getName } = require('../utils/reporter');
5
7
  const { ERROR_TYPES } = require('../utils/error-types');
@@ -38,7 +40,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
38
40
  reportErrors(node, reporter, {
39
41
  message: 'Tool returns nothing to the agent. Set a "toolCallResult" (at minimum, note the task completed).',
40
42
  data: { type: ERROR_TYPES.AGENT_TOOL_RESULT_MISSING },
41
- propertiesPanel: { entryIds: [ 'outputs' ] },
43
+ path: getOutputsPath(node),
42
44
  });
43
45
  return;
44
46
  }
@@ -51,7 +53,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
51
53
  reportErrors(casingMismatch.element, reporter, {
52
54
  message: `Wrong casing "${getCasingMismatchText(casingMismatch)}": use toolCallResult (case-sensitive).`,
53
55
  data: { type: ERROR_TYPES.AGENT_TOOL_OUTPUT_KEY_CASING_INVALID },
54
- propertiesPanel: { entryIds: [ 'outputs' ] },
56
+ path: getChannelPath(casingMismatch),
55
57
  });
56
58
  return;
57
59
  }
@@ -63,7 +65,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
63
65
  reportErrors(misdirected.element, reporter, {
64
66
  message: '"toolCallResult" output is not mapped.',
65
67
  data: { type: ERROR_TYPES.AGENT_TOOL_OUTPUT_KEY_INVALID },
66
- propertiesPanel: { entryIds: [ 'outputs' ] },
68
+ path: getChannelPath(misdirected),
67
69
  });
68
70
  return;
69
71
  }
@@ -96,7 +98,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
96
98
  reportErrors(overwriter.element, reporter, {
97
99
  message: `This overwrites the "toolCallResult" value set on "${overwrittenLabel}".`,
98
100
  data: { type: ERROR_TYPES.AGENT_TOOL_OUTPUT_KEY_OVERWRITE },
99
- propertiesPanel: { entryIds: [ 'outputs' ] },
101
+ path: getChannelPath(overwriter),
100
102
  });
101
103
  }
102
104
  }
@@ -120,8 +122,9 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
120
122
  *
121
123
  * @param {ModdleElement} entry
122
124
  *
123
- * @returns {Object} { channels, linear } — channels as { kind, value, element }
124
- * (element being whichever element in the flow actually wrote this channel),
125
+ * @returns {Object} { channels, linear } — channels as { kind, value, element,
126
+ * node, property } (element being whichever element in the flow actually wrote
127
+ * this channel; node/property the moddle leaf that carries the offending value),
125
128
  * and linear being true when the flow is a single non-branching chain
126
129
  */
127
130
  function collectResultChannels(entry) {
@@ -191,14 +194,14 @@ function collectElementChannels(element, channels) {
191
194
  for (const value of extensionElements.get('values')) {
192
195
  if (is(value, 'zeebe:IoMapping')) {
193
196
  for (const output of value.get('outputParameters') || []) {
194
- channels.push({ kind: 'output', value: output.get('target') || '', source: output.get('source'), element });
197
+ channels.push({ kind: 'output', value: output.get('target') || '', source: output.get('source'), element, node: output, property: 'target' });
195
198
  }
196
199
  }
197
200
 
198
201
  if (is(value, 'zeebe:Script') || is(value, 'zeebe:CalledDecision')) {
199
202
  const resultVariable = value.get('resultVariable');
200
203
  if (resultVariable) {
201
- channels.push({ kind: 'resultVariable', value: resultVariable, element });
204
+ channels.push({ kind: 'resultVariable', value: resultVariable, element, node: value, property: 'resultVariable' });
202
205
  }
203
206
  }
204
207
 
@@ -212,16 +215,45 @@ function collectElementChannels(element, channels) {
212
215
  for (const header of value.get('values') || []) {
213
216
  const key = header.get('key');
214
217
  if (key === 'resultVariable') {
215
- channels.push({ kind: 'resultVariable', value: header.get('value') || '', element });
218
+ channels.push({ kind: 'resultVariable', value: header.get('value') || '', element, node: header, property: 'value' });
216
219
  }
217
220
  if (key === 'resultExpression') {
218
- channels.push({ kind: 'resultExpression', value: header.get('value') || '', element });
221
+ channels.push({ kind: 'resultExpression', value: header.get('value') || '', element, node: header, property: 'value' });
219
222
  }
220
223
  }
221
224
  }
222
225
  }
223
226
  }
224
227
 
228
+ // The moddle leaf path to the write that actually carries the offending value
229
+ // (an output target, a script/decision resultVariable, a connector header
230
+ // value), relative to the element the finding is reported on. The panel
231
+ // resolves this render-agnostically to the concrete field — a standard output
232
+ // entry, or the matching element-template field when the tool is template
233
+ // bound. Degrades to null (element selection) if the node is detached.
234
+ function getChannelPath(channel) {
235
+ return pathConcat(getPath(channel.node, channel.element), channel.property);
236
+ }
237
+
238
+ // Best-effort location for a "tool returns nothing" finding: the tool's output
239
+ // mapping collection, which the panel resolves outward to the outputs group.
240
+ // When the tool has no output mapping at all there is nothing to point at, so
241
+ // the finding degrades to element selection (null path) rather than fabricate a
242
+ // target.
243
+ function getOutputsPath(element) {
244
+ const extensionElements = element.get('extensionElements');
245
+ if (!extensionElements) {
246
+ return null;
247
+ }
248
+
249
+ const ioMapping = (extensionElements.get('values') || []).find(value => is(value, 'zeebe:IoMapping'));
250
+ if (!ioMapping) {
251
+ return null;
252
+ }
253
+
254
+ return pathConcat(getPath(ioMapping, element), 'outputParameters');
255
+ }
256
+
225
257
  function isToolCallResultChannel({ kind, value }) {
226
258
  if (kind === 'resultExpression') {
227
259
  return /\btoolCallResult\b/.test(value);
@@ -1,6 +1,6 @@
1
1
  const { is } = require('bpmnlint-utils');
2
2
 
3
- const { getPath } = require('@bpmn-io/moddle-utils');
3
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
4
4
 
5
5
  const {
6
6
  ERROR_TYPES,
@@ -33,7 +33,7 @@ module.exports = skipInNonExecutableProcess(function() {
33
33
  return [
34
34
  {
35
35
  message: 'Property <eventType> of <zeebe:ExecutionListener> with value <beforeAll> only allowed on multi-instance elements',
36
- path: [ ...getPath(listener, node), 'eventType' ],
36
+ path: pathConcat(getPath(listener, node), 'eventType'),
37
37
  data: {
38
38
  type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED,
39
39
  node: listener,
@@ -1,4 +1,4 @@
1
- const { getPath } = require('@bpmn-io/moddle-utils');
1
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
2
2
 
3
3
  const { findExtensionElement } = require('../../utils/element');
4
4
 
@@ -75,7 +75,7 @@ function getInboundConnectorError(node, propertyName, allowedVersion) {
75
75
 
76
76
  return {
77
77
  message: `Connector property <name> with value <${ propertyName }> only allowed by Camunda ${ allowedVersion } or newer.`,
78
- path: path ? [ ...path, 'name' ] : [ 'name' ],
78
+ path: pathConcat(path || [], 'name'),
79
79
  data: {
80
80
  type: ERROR_TYPES.CONNECTORS_PROPERTY_VALUE_NOT_ALLOWED,
81
81
  node: property,
@@ -5,6 +5,7 @@ const {
5
5
 
6
6
  const {
7
7
  getEventDefinition,
8
+ getReferencePath,
8
9
  hasProperties
9
10
  } = require('../utils/element');
10
11
 
@@ -52,11 +53,19 @@ module.exports = skipInNonExecutableProcess(function({ version }) {
52
53
  return;
53
54
  }
54
55
 
56
+ const nodePath = getReferencePath({
57
+ element: node,
58
+ referenceHolder: eventDefinition,
59
+ referenceProperty: 'errorRef',
60
+ referencedRoot: errorRef,
61
+ node: errorRef
62
+ });
63
+
55
64
  errors = hasProperties(errorRef, {
56
65
  errorCode: {
57
66
  required: true
58
67
  }
59
- }, node);
68
+ }, node, nodePath);
60
69
 
61
70
  if (errors.length) {
62
71
  reportErrors(node, reporter, errors);
@@ -5,6 +5,7 @@ const {
5
5
 
6
6
  const {
7
7
  getEventDefinition,
8
+ getReferencePath,
8
9
  hasProperties
9
10
  } = require('../utils/element');
10
11
 
@@ -47,11 +48,19 @@ module.exports = skipInNonExecutableProcess(function() {
47
48
  return;
48
49
  }
49
50
 
51
+ const nodePath = getReferencePath({
52
+ element: node,
53
+ referenceHolder: eventDefinition,
54
+ referenceProperty: 'escalationRef',
55
+ referencedRoot: escalationRef,
56
+ node: escalationRef
57
+ });
58
+
50
59
  errors = hasProperties(escalationRef, {
51
60
  escalationCode: {
52
61
  required: true
53
62
  }
54
- }, node);
63
+ }, node, nodePath);
55
64
 
56
65
  if (errors.length) {
57
66
  reportErrors(node, reporter, errors);
@@ -1,6 +1,6 @@
1
1
  const { is } = require('bpmnlint-utils');
2
2
 
3
- const { getPath } = require('@bpmn-io/moddle-utils');
3
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
4
4
  const { camundaBuiltins, camundaReservedNameBuiltins } = require('@camunda/feel-builtins');
5
5
 
6
6
  const { FeelAnalyzer } = require('@bpmn-io/feel-analyzer');
@@ -77,9 +77,7 @@ module.exports = skipInNonExecutableProcess(function(config = {}) {
77
77
 
78
78
  errors.push({
79
79
  message: `FEEL function <${ builtin.name }> requires Camunda ${ builtin.engines.camunda }`,
80
- path: path
81
- ? [ ...path, propertyName ]
82
- : [ propertyName ],
80
+ path: pathConcat(path || [], propertyName),
83
81
  data: {
84
82
  type: ERROR_TYPES.FEEL_EXPRESSION_UNSUPPORTED_FUNCTION,
85
83
  node,
@@ -1,6 +1,6 @@
1
1
  const { is } = require('bpmnlint-utils');
2
2
 
3
- const { getPath } = require('@bpmn-io/moddle-utils');
3
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
4
4
 
5
5
  const { FeelAnalyzer } = require('@bpmn-io/feel-analyzer');
6
6
 
@@ -52,9 +52,7 @@ module.exports = skipInNonExecutableProcess(function() {
52
52
 
53
53
  errors.push({
54
54
  message: `Property <${ propertyName }> is not a valid FEEL expression`,
55
- path: path
56
- ? [ ...path, propertyName ]
57
- : [ propertyName ],
55
+ path: pathConcat(path || [], propertyName),
58
56
  data: {
59
57
  type: ERROR_TYPES.FEEL_EXPRESSION_INVALID,
60
58
  node,
@@ -1,4 +1,4 @@
1
- const { getPath } = require('@bpmn-io/moddle-utils');
1
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
2
2
 
3
3
  const {
4
4
  is,
@@ -72,9 +72,7 @@ module.exports = skipInNonExecutableProcess(function() {
72
72
 
73
73
  reportErrors(linkCatchEvent, reporter, {
74
74
  message: `Property of type <bpmn:LinkEventDefinition> has property <name> with duplicate value of <${ name }>`,
75
- path: path
76
- ? [ ...path, 'name' ]
77
- : [ 'name' ],
75
+ path: pathConcat(path || [], 'name'),
78
76
  data: {
79
77
  type: ERROR_TYPES.ELEMENT_PROPERTY_VALUE_DUPLICATED,
80
78
  node: linkEventDefinition,
@@ -5,6 +5,7 @@ const {
5
5
 
6
6
  const {
7
7
  getEventDefinition,
8
+ getReferencePath,
8
9
  hasProperties
9
10
  } = require('../utils/element');
10
11
 
@@ -45,11 +46,19 @@ module.exports = skipInNonExecutableProcess(function() {
45
46
 
46
47
  const messageRef = eventDefinitionOrReceiveTask.get('messageRef');
47
48
 
49
+ const nodePath = getReferencePath({
50
+ element: node,
51
+ referenceHolder: eventDefinitionOrReceiveTask,
52
+ referenceProperty: 'messageRef',
53
+ referencedRoot: messageRef,
54
+ node: messageRef
55
+ });
56
+
48
57
  errors = hasProperties(messageRef, {
49
58
  name: {
50
59
  required: true
51
60
  }
52
- }, node);
61
+ }, node, nodePath);
53
62
 
54
63
  if (errors && errors.length) {
55
64
  reportErrors(node, reporter, errors);
@@ -1,4 +1,4 @@
1
- const { getPath } = require('@bpmn-io/moddle-utils');
1
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
2
2
 
3
3
  const {
4
4
  findExtensionElement
@@ -30,7 +30,7 @@ module.exports = skipInNonExecutableProcess(function() {
30
30
  return [
31
31
  {
32
32
  message: `Property <eventType> of <zeebe:ExecutionListener> with value <beforeAll> only allowed by Camunda ${ ALLOWED_VERSION } or newer`,
33
- path: [ ...getPath(listener, node), 'eventType' ],
33
+ path: pathConcat(getPath(listener, node), 'eventType'),
34
34
  data: {
35
35
  type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED,
36
36
  node: listener,
@@ -2,7 +2,7 @@ const {
2
2
  is
3
3
  } = require('bpmnlint-utils');
4
4
 
5
- const { getPath } = require('@bpmn-io/moddle-utils');
5
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
6
6
 
7
7
  const {
8
8
  ERROR_TYPES,
@@ -103,9 +103,7 @@ function noExpression(node, propertyName, parentNode, allowedVersion) {
103
103
 
104
104
  return {
105
105
  message,
106
- path: path
107
- ? [ ...path, propertyName ]
108
- : [ propertyName ],
106
+ path: pathConcat(path || [], propertyName),
109
107
  data
110
108
  };
111
109
  }
@@ -1,3 +1,5 @@
1
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
2
+
1
3
  const { hasNoExtensionElement } = require('../utils/element');
2
4
 
3
5
  const { reportErrors } = require('../utils/reporter');
@@ -9,6 +11,29 @@ module.exports = skipInNonExecutableProcess(function() {
9
11
  const errors = hasNoExtensionElement(node, 'zeebe:TaskSchedule', node, '8.2');
10
12
 
11
13
  if (errors && errors.length) {
14
+
15
+ // add one leaf path per offending schedule field that is set, so consumers
16
+ // resolve entry ids render-agnostically
17
+ errors.forEach(error => {
18
+ const { extensionElement: taskSchedule } = error.data;
19
+
20
+ const path = getPath(taskSchedule, node);
21
+
22
+ const paths = [];
23
+
24
+ if (path && taskSchedule.get('dueDate')) {
25
+ paths.push(pathConcat(path, 'dueDate'));
26
+ }
27
+
28
+ if (path && taskSchedule.get('followUpDate')) {
29
+ paths.push(pathConcat(path, 'followUpDate'));
30
+ }
31
+
32
+ if (paths.length) {
33
+ error.paths = paths;
34
+ }
35
+ });
36
+
12
37
  reportErrors(node, reporter, errors);
13
38
  }
14
39
  }
@@ -1,3 +1,5 @@
1
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
2
+
1
3
  const { hasNoExtensionElement } = require('../utils/element');
2
4
 
3
5
  const { reportErrors } = require('../utils/reporter');
@@ -9,6 +11,22 @@ module.exports = skipInNonExecutableProcess(function() {
9
11
  const errors = hasNoExtensionElement(node, 'zeebe:Properties', node, '8.1');
10
12
 
11
13
  if (errors && errors.length) {
14
+
15
+ // add one leaf path per offending property `name` field, so consumers
16
+ // resolve entry ids render-agnostically
17
+ errors.forEach(error => {
18
+ const { extensionElement } = error.data;
19
+
20
+ const paths = extensionElement.get('properties')
21
+ .map(property => getPath(property, node))
22
+ .filter(path => path)
23
+ .map(path => pathConcat(path, 'name'));
24
+
25
+ if (paths.length) {
26
+ error.paths = paths;
27
+ }
28
+ });
29
+
12
30
  reportErrors(node, reporter, errors);
13
31
  }
14
32
  }
@@ -2,7 +2,7 @@ const { isString } = require('min-dash');
2
2
 
3
3
  const { is } = require('bpmnlint-utils');
4
4
 
5
- const { getPath } = require('@bpmn-io/moddle-utils');
5
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
6
6
 
7
7
  const {
8
8
  findExtensionElement,
@@ -99,9 +99,7 @@ function getReport(propertyName, node, parentNode) {
99
99
 
100
100
  return {
101
101
  message: `Property <${ propertyName }> uses deprecated secret expression format`,
102
- path: path
103
- ? [ ...getPath(node, parentNode), propertyName ]
104
- : [ propertyName ],
102
+ path: pathConcat(path || [], propertyName),
105
103
  data: {
106
104
  type: ERROR_TYPES.SECRET_EXPRESSION_FORMAT_DEPRECATED,
107
105
  node,
@@ -5,6 +5,7 @@ const {
5
5
 
6
6
  const {
7
7
  getEventDefinition,
8
+ getReferencePath,
8
9
  hasProperties
9
10
  } = require('../utils/element');
10
11
 
@@ -48,11 +49,19 @@ module.exports = skipInNonExecutableProcess(function() {
48
49
  return;
49
50
  }
50
51
 
52
+ const nodePath = getReferencePath({
53
+ element: node,
54
+ referenceHolder: eventDefinition,
55
+ referenceProperty: 'signalRef',
56
+ referencedRoot: signalRef,
57
+ node: signalRef
58
+ });
59
+
51
60
  errors = hasProperties(signalRef, {
52
61
  name: {
53
62
  required: true
54
63
  }
55
- }, node);
64
+ }, node, nodePath);
56
65
 
57
66
  if (errors.length) {
58
67
  reportErrors(node, reporter, errors);
@@ -6,6 +6,7 @@ const {
6
6
  const {
7
7
  findExtensionElement,
8
8
  getEventDefinition,
9
+ getReferencePath,
9
10
  hasExtensionElement,
10
11
  hasProperties
11
12
  } = require('../utils/element');
@@ -49,11 +50,19 @@ module.exports = skipInNonExecutableProcess(function() {
49
50
 
50
51
  const subscription = findExtensionElement(messageRef, 'zeebe:Subscription');
51
52
 
53
+ const nodePath = getReferencePath({
54
+ element: node,
55
+ referenceHolder: eventDefinitionOrReceiveTask,
56
+ referenceProperty: 'messageRef',
57
+ referencedRoot: messageRef,
58
+ node: subscription
59
+ });
60
+
52
61
  errors = hasProperties(subscription, {
53
62
  correlationKey: {
54
63
  required: true
55
64
  }
56
- }, node);
65
+ }, node, nodePath);
57
66
 
58
67
  if (errors && errors.length) {
59
68
  reportErrors(node, reporter, errors);
@@ -16,7 +16,7 @@ const {
16
16
  isAny
17
17
  } = require('bpmnlint-utils');
18
18
 
19
- const { getPath } = require('@bpmn-io/moddle-utils');
19
+ const { getPath, pathConcat } = require('@bpmn-io/moddle-utils');
20
20
 
21
21
  const { ERROR_TYPES } = require('./error-types');
22
22
 
@@ -24,6 +24,59 @@ const { greaterOrEqual } = require('./version');
24
24
 
25
25
  module.exports.ERROR_TYPES = ERROR_TYPES;
26
26
 
27
+ /**
28
+ * Build the `{ paths }` fragment for a multi-field finding: one leaf moddle path
29
+ * per offending node, rooted at `parentNode`. Nodes whose path can't be resolved
30
+ * are dropped; the fragment is empty when none resolve, so the `paths` key is
31
+ * only present when it carries usable locations.
32
+ *
33
+ * @param {Array<Object>} nodes
34
+ * @param {string} propertyName leaf property appended to each node's path
35
+ * @param {Object} [parentNode]
36
+ *
37
+ * @returns {{ paths?: Array<Array<string|number>> }}
38
+ */
39
+ function leafPaths(nodes, propertyName, parentNode = null) {
40
+ const paths = nodes
41
+ .map(node => getPath(node, parentNode))
42
+ .filter(path => isArray(path))
43
+ .map(path => pathConcat(path, propertyName));
44
+
45
+ return paths.length ? { paths } : {};
46
+ }
47
+
48
+ /**
49
+ * Build a moddle path that locates `node` relative to `element` by following a
50
+ * single reference edge.
51
+ *
52
+ * `getPath` (from @bpmn-io/moddle-utils) only walks containment (`$parent`), so
53
+ * it roots any referenced node at `bpmn:Definitions` — a path that cannot be
54
+ * resolved starting from `element`. This helper instead stitches together
55
+ *
56
+ * getPath(referenceHolder, element) + referenceProperty + getPath(node, referencedRoot)
57
+ *
58
+ * yielding a path that resolves locally from `element`, because moddle
59
+ * `get(referenceProperty)` transparently follows the reference.
60
+ *
61
+ * @param {Object} options
62
+ * @param {Object} options.element root element the path is relative to
63
+ * @param {Object} options.referenceHolder node holding the reference (contained in `element`)
64
+ * @param {string} options.referenceProperty name of the reference property
65
+ * @param {Object} options.referencedRoot referenced root element
66
+ * @param {Object} options.node checked node (contained in `referencedRoot`)
67
+ *
68
+ * @returns {Array<string|number>|null} null when a segment can't be resolved
69
+ */
70
+ function getReferencePath({ element, referenceHolder, referenceProperty, referencedRoot, node }) {
71
+ return pathConcat(
72
+ getPath(referenceHolder, element),
73
+ referenceProperty,
74
+ getPath(node, referencedRoot)
75
+ );
76
+ }
77
+
78
+ module.exports.getReferencePath = getReferencePath;
79
+
27
80
  function getEventDefinition(node) {
28
81
  const eventDefinitions = node.get('eventDefinitions');
29
82
 
@@ -121,6 +174,10 @@ module.exports.hasDuplicatedPropertyValues = function(node, propertiesName, prop
121
174
  return {
122
175
  message: `Properties of type <${ duplicateProperties[ 0 ].$type }> have property <${ propertyName }> with duplicate value of <${ duplicate }>`,
123
176
  path: null,
177
+
178
+ // one leaf path per offending field, so consumers resolve entry ids
179
+ // render-agnostically (template field vs. standard field)
180
+ ...leafPaths(duplicateProperties, propertyName, parentNode),
124
181
  data: {
125
182
  type: ERROR_TYPES.PROPERTY_VALUE_DUPLICATED,
126
183
  node,
@@ -169,6 +226,11 @@ module.exports.hasDuplicatedPropertiesValues = function(node, containerPropertyN
169
226
  return {
170
227
  message: `Properties of type <${ duplicate.$type }> have properties with duplicate values (${ duplicatesSummary })`,
171
228
  path: null,
229
+
230
+ // one leaf path per offending location; `type` is the field that
231
+ // represents the duplicated element in the properties panel, so
232
+ // consumers resolve its entry id render-agnostically
233
+ ...leafPaths(duplicateProperties, 'type', parentNode),
172
234
  data: {
173
235
  type: ERROR_TYPES.PROPERTY_VALUES_DUPLICATED,
174
236
  node,
@@ -184,13 +246,13 @@ module.exports.hasDuplicatedPropertiesValues = function(node, containerPropertyN
184
246
  return [];
185
247
  };
186
248
 
187
- module.exports.hasProperties = function(node, properties, parentNode = null) {
249
+ module.exports.hasProperties = function(node, properties, parentNode = null, nodePath) {
188
250
  return Object.entries(properties).reduce((results, property) => {
189
251
  const [ propertyName, propertyChecks ] = property;
190
252
 
191
253
  const { allowedVersion = null } = propertyChecks;
192
254
 
193
- const path = getPath(node, parentNode);
255
+ const path = nodePath === undefined ? getPath(node, parentNode) : nodePath;
194
256
 
195
257
  const propertyValue = node.get(propertyName);
196
258
 
@@ -201,9 +263,7 @@ module.exports.hasProperties = function(node, properties, parentNode = null) {
201
263
  message: allowedVersion
202
264
  ? `Element of type <${ node.$type }> without property <${ propertyName }> only allowed by Camunda ${ allowedVersion } or newer`
203
265
  : `Element of type <${ node.$type }> must have property <${ propertyName }>`,
204
- path: path
205
- ? [ ...path, propertyName ]
206
- : [ propertyName ],
266
+ path: pathConcat(path || [], propertyName),
207
267
  data: addAllowedVersion({
208
268
  type: ERROR_TYPES.PROPERTY_REQUIRED,
209
269
  node,
@@ -222,9 +282,7 @@ module.exports.hasProperties = function(node, properties, parentNode = null) {
222
282
  ...results,
223
283
  {
224
284
  message: `Element of type <${ node.$type }> must have property <${ propertyName }> if it has property <${ propertyChecks.dependentRequired }>`,
225
- path: path
226
- ? [ ...path, propertyName ]
227
- : [ propertyName ],
285
+ path: pathConcat(path || [], propertyName),
228
286
  data: {
229
287
  type: ERROR_TYPES.PROPERTY_DEPENDENT_REQUIRED,
230
288
  node,
@@ -252,9 +310,7 @@ module.exports.hasProperties = function(node, properties, parentNode = null) {
252
310
  message: allowedVersion
253
311
  ? `Property <${ propertyName }> of type <${ propertyValue.$type }> only allowed by Camunda ${ allowedVersion } or newer`
254
312
  : `Property <${ propertyName }> of type <${ propertyValue.$type }> not allowed`,
255
- path: path
256
- ? [ ...path, propertyName ]
257
- : [ propertyName ],
313
+ path: pathConcat(path || [], propertyName),
258
314
  data: addAllowedVersion({
259
315
  type: ERROR_TYPES.PROPERTY_TYPE_NOT_ALLOWED,
260
316
  node,
@@ -271,9 +327,7 @@ module.exports.hasProperties = function(node, properties, parentNode = null) {
271
327
  ...results,
272
328
  {
273
329
  message: `Property <${ propertyName }> must have value of <${ propertyChecks.value }>`,
274
- path: path
275
- ? [ ...path, propertyName ]
276
- : [ propertyName ],
330
+ path: pathConcat(path || [], propertyName),
277
331
  data: {
278
332
  type: ERROR_TYPES.PROPERTY_VALUE_REQUIRED,
279
333
  node,
@@ -292,9 +346,7 @@ module.exports.hasProperties = function(node, properties, parentNode = null) {
292
346
  message: allowedVersion
293
347
  ? `Property <${ propertyName }> only allowed by Camunda ${ allowedVersion } or newer`
294
348
  : `Property <${ propertyName }> not allowed`,
295
- path: path
296
- ? [ ...path, propertyName ]
297
- : [ propertyName ],
349
+ path: pathConcat(path || [], propertyName),
298
350
  data: addAllowedVersion({
299
351
  type: ERROR_TYPES.PROPERTY_NOT_ALLOWED,
300
352
  node,
@@ -312,9 +364,7 @@ module.exports.hasProperties = function(node, properties, parentNode = null) {
312
364
  message: allowedVersion
313
365
  ? `Property value of <${ truncate(propertyValue) }> only allowed by Camunda ${ allowedVersion } or newer`
314
366
  : `Property value of <${ truncate(propertyValue) }> not allowed`,
315
- path: path
316
- ? [ ...path, propertyName ]
317
- : [ propertyName ],
367
+ path: pathConcat(path || [], propertyName),
318
368
  data: addAllowedVersion({
319
369
  type: ERROR_TYPES.PROPERTY_VALUE_NOT_ALLOWED,
320
370
  node,
@@ -432,9 +482,7 @@ module.exports.hasExpression = function(node, propertyName, check, parentNode =
432
482
  return [
433
483
  {
434
484
  message: `Property <${ propertyName }> must have expression value`,
435
- path: path
436
- ? [ ...path, propertyName ]
437
- : null,
485
+ path: pathConcat(path, propertyName),
438
486
  data: {
439
487
  type: ERROR_TYPES.EXPRESSION_REQUIRED,
440
488
  node: is(expression, 'bpmn:Expression') ? expression : node,
@@ -462,9 +510,7 @@ module.exports.hasExpression = function(node, propertyName, check, parentNode =
462
510
  message: allowedVersion
463
511
  ? `Expression value of <${ propertyValue }> only allowed by Camunda ${ allowedVersion }`
464
512
  : `Expression value of <${ propertyValue }> not allowed`,
465
- path: path
466
- ? [ ...path, propertyName ]
467
- : null,
513
+ path: pathConcat(path, propertyName),
468
514
  data: addAllowedVersion({
469
515
  type: ERROR_TYPES.EXPRESSION_VALUE_NOT_ALLOWED,
470
516
  node: is(expression, 'bpmn:Expression') ? expression : node,