Repository: bpmn-io/bpmnlint (js)

Issue:
Be Able to Specify Path When Reporting Issue Through Reporter#report
When using [`Reporter#report`](https://github.com/bpmn-io/bpmnlint/blob/master/lib/test-rule.js#L11) one can only provide the element ID and the error message. There is no way to provide additional properties e.g. the path of the property that has caused the error to be reported. I'd image something along the lines of:

```javascript
Reporter.report('ServiceTask_1', '<zeebe:TaskDefinition> must have <zeebe:type> property', {
  path: [ 'extensionElements', 'values', 0, 'zeebe:type' ]
});
```

Adding an optional third argument wouldn't introduce any breaking changes.

---

Related to https://github.com/bpmn-io/internal-docs/issues/430

Pull request:
feat: allow path property in Reporter#report
Closes #69

<!--

Thanks for creating this pull request!

Please make sure to link the issue you are closing as "Closes #issueNr". 
This helps us to understand the context of this PR.

-->


Patch:
diff --git a/bin/bpmnlint.js b/bin/bpmnlint.js
index 1e7fce8..37d3297 100755
--- a/bin/bpmnlint.js
+++ b/bin/bpmnlint.js
@@ -107,11 +107,21 @@ const categoryMap = {
  * Logs a formatted  message
  */
 function tableEntry(report) {
-  const category = report.category;
+  let {
+    category,
+    id = '',
+    message,
+    name = '',
+    path
+  } = report;
+
+  if (path) {
+    id = `${ id }#${ pathStringify(path) }`;
+  }
 
   const color = category === 'error' ? red : yellow;
 
-  return [ report.id || '', color(categoryMap[category] || category), report.message, report.name || '' ];
+  return [ id, color(categoryMap[ category ] || category), message, name ];
 }
 
 function createTable() {
@@ -384,4 +394,16 @@ Learn more about configuring bpmnlint: https://github.com/bpmn-io/bpmnlint#confi
   return lint(actualFiles, config);
 }
 
-run().catch(errorAndExit);
\ No newline at end of file
+run().catch(errorAndExit);
+
+// helpers //////////
+
+/**
+ * @param {(number|string)[]} path
+ * @param {string} [separator]
+ *
+ * @returns {string}
+ */
+function pathStringify(path, separator = '.') {
+  return path.join(separator);
+}
\ No newline at end of file
diff --git a/rules/conditional-flows.js b/rules/conditional-flows.js
index c972831..f2e93ab 100644
--- a/rules/conditional-flows.js
+++ b/rules/conditional-flows.js
@@ -20,7 +20,7 @@ module.exports = function() {
       );
 
       if (missingCondition) {
-        reporter.report(flow.id, 'Sequence flow is missing condition');
+        reporter.report(flow.id, 'Sequence flow is missing condition', [ 'conditionExpression' ]);
       }
     });
   }
diff --git a/rules/event-sub-process-typed-start-event.js b/rules/event-sub-process-typed-start-event.js
index 3142bfb..5273d2b 100644
--- a/rules/event-sub-process-typed-start-event.js
+++ b/rules/event-sub-process-typed-start-event.js
@@ -25,7 +25,7 @@ module.exports = function() {
       const eventDefinitions = flowElement.eventDefinitions || [];
 
       if (eventDefinitions.length === 0) {
-        reporter.report(flowElement.id, 'Start event is missing event definition');
+        reporter.report(flowElement.id, 'Start event is missing event definition', [ 'eventDefinitions' ]);
       }
     });
   }
diff --git a/rules/label-required.js b/rules/label-required.js
index 49c85d9..0003a55 100644
--- a/rules/label-required.js
+++ b/rules/label-required.js
@@ -50,7 +50,7 @@ module.exports = function() {
       const name = (node.name || '').trim();
 
       if (name.length === 0) {
-        reporter.report(node.id, 'Element is missing label/name');
+        reporter.report(node.id, 'Element is missing label/name', [ 'name' ]);
       }
     }
   }
diff --git a/rules/single-event-definition.js b/rules/single-event-definition.js
index 0e4a373..6097a76 100644
--- a/rules/single-event-definition.js
+++ b/rules/single-event-definition.js
@@ -17,7 +17,7 @@ module.exports = function() {
     const eventDefinitions = node.eventDefinitions || [];
 
     if (eventDefinitions.length > 1) {
-      reporter.report(node.id, 'Event has multiple event definitions');
+      reporter.report(node.id, 'Event has multiple event definitions', [ 'eventDefinitions' ]);
     }
   }
 
diff --git a/rules/sub-process-blank-start-event.js b/rules/sub-process-blank-start-event.js
index 7b690a3..14929a6 100644
--- a/rules/sub-process-blank-start-event.js
+++ b/rules/sub-process-blank-start-event.js
@@ -26,7 +26,7 @@ module.exports = function() {
       const eventDefinitions = flowElement.eventDefinitions || [];
 
       if (eventDefinitions.length > 0) {
-        reporter.report(flowElement.id, 'Start event must be blank');
+        reporter.report(flowElement.id, 'Start event must be blank', [ 'eventDefinitions' ]);
       }
     });
   }


Test patch:
diff --git a/lib/test-rule.js b/lib/test-rule.js
index 9044369..83bb165 100644
--- a/lib/test-rule.js
+++ b/lib/test-rule.js
@@ -8,8 +8,20 @@ class Reporter {
     this.report = this.report.bind(this);
   }
 
-  report(id, message) {
-    this.messages.push({ id, message });
+  report(id, message, path) {
+    let report = {
+      id,
+      message
+    };
+
+    if (path) {
+      report = {
+        ...report,
+        path
+      };
+    }
+
+    this.messages.push(report);
   }
 }
 
diff --git a/test/rules/conditional-flows.js b/test/rules/conditional-flows.js
index 22d5b44..a81e5b8 100644
--- a/test/rules/conditional-flows.js
+++ b/test/rules/conditional-flows.js
@@ -32,7 +32,8 @@ RuleTester.verify('conditional-flows', rule, {
       moddleElement: readModdle(__dirname + '/conditional-flows/invalid-fork-after-exclusive-gateway.bpmn'),
       report: {
         id: 'Flow_2',
-        message: 'Sequence flow is missing condition'
+        message: 'Sequence flow is missing condition',
+        path: [ 'conditionExpression' ]
       }
     },
     {
@@ -40,7 +41,8 @@ RuleTester.verify('conditional-flows', rule, {
       moddleElement: readModdle(__dirname + '/conditional-flows/invalid-fork-after-exclusive-gateway-default.bpmn'),
       report: {
         id: 'Flow_1',
-        message: 'Sequence flow is missing condition'
+        message: 'Sequence flow is missing condition',
+        path: [ 'conditionExpression' ]
       }
     },
     {
@@ -48,7 +50,8 @@ RuleTester.verify('conditional-flows', rule, {
       moddleElement: readModdle(__dirname + '/conditional-flows/invalid-fork-after-task.bpmn'),
       report: {
         id: 'Flow_1',
-        message: 'Sequence flow is missing condition'
+        message: 'Sequence flow is missing condition',
+        path: [ 'conditionExpression' ]
       }
     },
     {
@@ -56,7 +59,8 @@ RuleTester.verify('conditional-flows', rule, {
       moddleElement: readModdle(__dirname + '/conditional-flows/invalid-fork-after-task-default.bpmn'),
       report: {
         id: 'Flow_1',
-        message: 'Sequence flow is missing condition'
+        message: 'Sequence flow is missing condition',
+        path: [ 'conditionExpression' ]
       }
     }
   ]
diff --git a/test/rules/event-sub-process-typed-start-event.js b/test/rules/event-sub-process-typed-start-event.js
index 6abbc9a..4c434a7 100644
--- a/test/rules/event-sub-process-typed-start-event.js
+++ b/test/rules/event-sub-process-typed-start-event.js
@@ -30,7 +30,8 @@ RuleTester.verify('event-sub-process-typed-start-event', rule, {
       moddleElement: readModdle(__dirname + '/event-sub-process-typed-start-event/invalid.bpmn'),
       report: {
         id: 'StartEvent',
-        message: 'Start event is missing event definition'
+        message: 'Start event is missing event definition',
+        path: [ 'eventDefinitions' ]
       }
     }
   ]
diff --git a/test/rules/label-required.js b/test/rules/label-required.js
index f9f35e3..da2f0a6 100644
--- a/test/rules/label-required.js
+++ b/test/rules/label-required.js
@@ -36,42 +36,48 @@ RuleTester.verify('label-required', rule, {
       moddleElement: readModdle(__dirname + '/label-required/invalid-conditional-flow.bpmn'),
       report: {
         id: 'ConditionalFlow',
-        message
+        message,
+        path: [ 'name' ]
       }
     },
     {
       moddleElement: readModdle(__dirname + '/label-required/invalid-event.bpmn'),
       report: {
         id: 'Element',
-        message
+        message,
+        path: [ 'name' ]
       }
     },
     {
       moddleElement: readModdle(__dirname + '/label-required/invalid-gateway-split.bpmn'),
       report: {
         id: 'Element',
-        message
+        message,
+        path: [ 'name' ]
       }
     },
     {
       moddleElement: readModdle(__dirname + '/label-required/invalid-participant.bpmn'),
       report: {
         id: 'Element',
-        message
+        message,
+        path: [ 'name' ]
       }
     },
     {
       moddleElement: readModdle(__dirname + '/label-required/invalid-lane.bpmn'),
       report: {
         id: 'Element',
-        message
+        message,
+        path: [ 'name' ]
       }
     },
     {
       moddleElement: readModdle(__dirname + '/label-required/invalid-task.bpmn'),
       report: {
         id: 'Element',
-        message
+        message,
+        path: [ 'name' ]
       }
     }
   ]
diff --git a/test/rules/single-event-definition.js b/test/rules/single-event-definition.js
index eb5649e..4555930 100644
--- a/test/rules/single-event-definition.js
+++ b/test/rules/single-event-definition.js
@@ -21,7 +21,8 @@ RuleTester.verify('single-event-definition', rule, {
       moddleElement: readModdle(__dirname + '/single-event-definition/invalid.bpmn'),
       report: {
         id: 'Event',
-        message: 'Event has multiple event definitions'
+        message: 'Event has multiple event definitions',
+        path: [ 'eventDefinitions' ]
       }
     }
   ]
diff --git a/test/rules/sub-process-blank-start-event.js b/test/rules/sub-process-blank-start-event.js
index 64b6bfb..4b25553 100644
--- a/test/rules/sub-process-blank-start-event.js
+++ b/test/rules/sub-process-blank-start-event.js
@@ -27,7 +27,8 @@ RuleTester.verify('sub-process-blank-start-event', rule, {
       moddleElement: readModdle(__dirname + '/sub-process-blank-start-event/invalid.bpmn'),
       report: {
         id: 'StartEvent',
-        message: 'Start event must be blank'
+        message: 'Start event must be blank',
+        path: [ 'eventDefinitions' ]
       }
     }
   ]
diff --git a/test/spec/test-rule-spec.js b/test/spec/test-rule-spec.js
index 13986fb..dab4214 100644
--- a/test/spec/test-rule-spec.js
+++ b/test/spec/test-rule-spec.js
@@ -16,7 +16,7 @@ describe('test-rule', function() {
   });
 
 
-  it('should return check function reported messages', () => {
+  it('should return check function reported messages (id, message)', () => {
 
     // given
     const expectedMessages = [
@@ -35,6 +35,26 @@ describe('test-rule', function() {
   });
 
 
+  it('should return check function reported messages (id, message, path)', () => {
+
+    // given
+    const expectedMessages = [
+      {
+        id: 'Collaboration_0wzd2dx',
+        message: 'Collaboration detected',
+        path: [ 'rootElements', 0 ]
+      }
+    ];
+    const messages = testRule({
+      moddleRoot,
+      rule: createRule(fakeCheckRuleWithPath)
+    });
+
+    // then
+    expect(messages).to.eql(expectedMessages);
+  });
+
+
   it('should return { enter, leave } hook reported messages', () => {
 
     // given
@@ -85,6 +105,16 @@ function fakeCheckRuleWithReports() {
   return { check };
 }
 
+function fakeCheckRuleWithPath() {
+  function check(node, reporter) {
+    if (is(node, 'Collaboration')) {
+      reporter.report(node.id, 'Collaboration detected', [ 'rootElements', 0 ]);
+    }
+  }
+
+  return { check };
+}
+
 function fakeEnterLeaveRuleWithReports() {
   function enter(node, reporter) {
     if (is(node, 'Definitions')) {