diff --git a/config/all.js b/config/all.js
index c70709d..6a8890d 100644
--- a/config/all.js
+++ b/config/all.js
@@ -4,6 +4,7 @@ const allRules = [
'event-sub-process-typed-start-event',
'fake-join',
'label-required',
+ 'link-event',
'no-bpmndi',
'no-complex-gateway',
'no-disconnected',
diff --git a/config/recommended.js b/config/recommended.js
index 4f2e778..5eee56a 100644
--- a/config/recommended.js
+++ b/config/recommended.js
@@ -5,6 +5,7 @@ module.exports = {
'event-sub-process-typed-start-event': 'error',
'fake-join': 'warn',
'label-required': 'error',
+ 'link-event': 'error',
'no-bpmndi': 'error',
'no-complex-gateway': 'error',
'no-disconnected': 'error',
diff --git a/docs/rules/examples/link-event-correct.bpmn b/docs/rules/examples/link-event-correct.bpmn
new file mode 100644
index 0000000..7a1697c
--- /dev/null
+++ b/docs/rules/examples/link-event-correct.bpmn
@@ -0,0 +1,51 @@
+
+
+
+
+ Flow_0w6huzu
+
+
+
+ Flow_0w6huzu
+
+
+
+ Flow_1yhu2w5
+
+
+ Flow_1yhu2w5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/rules/examples/link-event-correct.png b/docs/rules/examples/link-event-correct.png
new file mode 100644
index 0000000..98282ca
Binary files /dev/null and b/docs/rules/examples/link-event-correct.png differ
diff --git a/docs/rules/examples/link-event-incorrect.bpmn b/docs/rules/examples/link-event-incorrect.bpmn
new file mode 100644
index 0000000..4d6f7d8
--- /dev/null
+++ b/docs/rules/examples/link-event-incorrect.bpmn
@@ -0,0 +1,78 @@
+
+
+
+
+ Flow_0w6huzu
+
+
+
+ Flow_0w6huzu
+
+
+
+
+ Flow_1v2vhxj
+
+
+
+ Flow_1v2vhxj
+
+
+
+
+
+ Flow_1m6xxsl
+
+
+
+ Flow_1m6xxsl
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/rules/examples/link-event-incorrect.png b/docs/rules/examples/link-event-incorrect.png
new file mode 100644
index 0000000..1bb6adc
Binary files /dev/null and b/docs/rules/examples/link-event-incorrect.png differ
diff --git a/docs/rules/link-event.md b/docs/rules/link-event.md
new file mode 100644
index 0000000..1644b69
--- /dev/null
+++ b/docs/rules/link-event.md
@@ -0,0 +1,20 @@
+# Link Event (link-event)
+
+A rule that checks that link events are used in accordance with the BPMN specification:
+
+* A link event must be named
+* For every link throw event there exists a matching catch event in the same scope
+
+
+Example of __incorrect__ usage for this rule:
+
+
+
+Cf. [`link-event-incorrect.bpmn`](./examples/link-event-incorrect.bpmn).
+
+
+Example of __correct__ usage for this rule:
+
+
+
+Cf. [`link-event-correct.bpmn`](./examples/link-event-correct.bpmn).
diff --git a/package.json b/package.json
index 5dcc1ac..0ae92cc 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"license": "MIT",
"scripts": {
"all": "run-s lint test-coverage",
- "lint": "eslint .",
+ "lint": "eslint . --ext js,mjs",
"dev": "npm test -- --watch",
"test": "mocha --exclude 'test/integration/*/**' 'test/**/*.*js'",
"test-coverage": "nyc --reporter=lcov --reporter=html npm test",
diff --git a/rules/link-event.js b/rules/link-event.js
new file mode 100644
index 0000000..7934a59
--- /dev/null
+++ b/rules/link-event.js
@@ -0,0 +1,100 @@
+const {
+ groupBy
+} = require('min-dash');
+
+const {
+ is
+} = require('bpmnlint-utils');
+
+
+/**
+ * A rule that verifies that link events are properly used.
+ *
+ * This implies:
+ *
+ * * for every link throw there exists a link catch within
+ * the same scope, and vice versa
+ * * there exists only a single pair of [ throw, catch ] links
+ * with a given name, per scope
+ * * link events have a name
+ *
+ */
+module.exports = function() {
+
+ function check(node, reporter) {
+
+ if (!is(node, 'bpmn:FlowElementsContainer')) {
+ return;
+ }
+
+ const links = (node.flowElements || []).filter(isLinkEvent);
+
+ for (const link of links) {
+ if (!link.name) {
+ reporter.report(link.id, 'Link event is missing name');
+ }
+ }
+
+ const names = groupBy(links, (link) => link.name);
+
+ for (const [ name, events ] of Object.entries(names)) {
+
+ // ignore unnamed (validated earlier)
+ if (!name) {
+ continue;
+ }
+
+ // missing catch or throw event
+ if (events.length === 1) {
+ const event = events[0];
+
+ reporter.report(event.id, `Link ${isThrowEvent(event) ? 'catch' : 'throw' } event with name <${ name }> missing in scope`);
+ }
+
+ const throwEvents = events.filter(isThrowEvent);
+
+ if (throwEvents.length > 1) {
+ for (const event of throwEvents) {
+ reporter.report(event.id, `Duplicate link throw event with name <${name}> in scope`);
+ }
+ }
+
+ const catchEvents = events.filter(isCatchEvent);
+
+ if (catchEvents.length > 1) {
+ for (const event of catchEvents) {
+ reporter.report(event.id, `Duplicate link catch event with name <${name}> in scope`);
+ }
+ }
+ }
+
+ }
+
+ return {
+ check
+ };
+};
+
+
+// helpers /////////////////
+
+function isLinkEvent(node) {
+
+ var eventDefinitions = node.eventDefinitions || [];
+
+ if (!is(node, 'bpmn:Event')) {
+ return false;
+ }
+
+ return eventDefinitions.some(
+ definition => is(definition, 'bpmn:LinkEventDefinition')
+ );
+}
+
+function isThrowEvent(node) {
+ return is(node, 'bpmn:ThrowEvent');
+}
+
+function isCatchEvent(node) {
+ return is(node, 'bpmn:CatchEvent');
+}
\ No newline at end of file
diff --git a/test/integration/compilation/test/bpmnlintrc.expected.js b/test/integration/compilation/test/bpmnlintrc.expected.js
index c88860b..4d6f171 100644
--- a/test/integration/compilation/test/bpmnlintrc.expected.js
+++ b/test/integration/compilation/test/bpmnlintrc.expected.js
@@ -34,6 +34,7 @@ const rules = {
"event-sub-process-typed-start-event": "error",
"fake-join": "warn",
"label-required": "error",
+ "link-event": "error",
"no-bpmndi": "error",
"no-complex-gateway": "error",
"no-disconnected": "warn",
@@ -90,86 +91,90 @@ import rule_4 from 'bpmnlint/rules/label-required';
cache['bpmnlint/label-required'] = rule_4;
-import rule_5 from 'bpmnlint/rules/no-bpmndi';
+import rule_5 from 'bpmnlint/rules/link-event';
-cache['bpmnlint/no-bpmndi'] = rule_5;
+cache['bpmnlint/link-event'] = rule_5;
-import rule_6 from 'bpmnlint/rules/no-complex-gateway';
+import rule_6 from 'bpmnlint/rules/no-bpmndi';
-cache['bpmnlint/no-complex-gateway'] = rule_6;
+cache['bpmnlint/no-bpmndi'] = rule_6;
-import rule_7 from 'bpmnlint/rules/no-disconnected';
+import rule_7 from 'bpmnlint/rules/no-complex-gateway';
-cache['bpmnlint/no-disconnected'] = rule_7;
+cache['bpmnlint/no-complex-gateway'] = rule_7;
-import rule_8 from 'bpmnlint/rules/no-duplicate-sequence-flows';
+import rule_8 from 'bpmnlint/rules/no-disconnected';
-cache['bpmnlint/no-duplicate-sequence-flows'] = rule_8;
+cache['bpmnlint/no-disconnected'] = rule_8;
-import rule_9 from 'bpmnlint/rules/no-gateway-join-fork';
+import rule_9 from 'bpmnlint/rules/no-duplicate-sequence-flows';
-cache['bpmnlint/no-gateway-join-fork'] = rule_9;
+cache['bpmnlint/no-duplicate-sequence-flows'] = rule_9;
-import rule_10 from 'bpmnlint/rules/no-implicit-split';
+import rule_10 from 'bpmnlint/rules/no-gateway-join-fork';
-cache['bpmnlint/no-implicit-split'] = rule_10;
+cache['bpmnlint/no-gateway-join-fork'] = rule_10;
-import rule_11 from 'bpmnlint/rules/no-implicit-end';
+import rule_11 from 'bpmnlint/rules/no-implicit-split';
-cache['bpmnlint/no-implicit-end'] = rule_11;
+cache['bpmnlint/no-implicit-split'] = rule_11;
-import rule_12 from 'bpmnlint/rules/no-implicit-start';
+import rule_12 from 'bpmnlint/rules/no-implicit-end';
-cache['bpmnlint/no-implicit-start'] = rule_12;
+cache['bpmnlint/no-implicit-end'] = rule_12;
-import rule_13 from 'bpmnlint/rules/no-inclusive-gateway';
+import rule_13 from 'bpmnlint/rules/no-implicit-start';
-cache['bpmnlint/no-inclusive-gateway'] = rule_13;
+cache['bpmnlint/no-implicit-start'] = rule_13;
-import rule_14 from 'bpmnlint/rules/no-overlapping-elements';
+import rule_14 from 'bpmnlint/rules/no-inclusive-gateway';
-cache['bpmnlint/no-overlapping-elements'] = rule_14;
+cache['bpmnlint/no-inclusive-gateway'] = rule_14;
-import rule_15 from 'bpmnlint/rules/single-blank-start-event';
+import rule_15 from 'bpmnlint/rules/no-overlapping-elements';
-cache['bpmnlint/single-blank-start-event'] = rule_15;
+cache['bpmnlint/no-overlapping-elements'] = rule_15;
-import rule_16 from 'bpmnlint/rules/single-event-definition';
+import rule_16 from 'bpmnlint/rules/single-blank-start-event';
-cache['bpmnlint/single-event-definition'] = rule_16;
+cache['bpmnlint/single-blank-start-event'] = rule_16;
-import rule_17 from 'bpmnlint/rules/start-event-required';
+import rule_17 from 'bpmnlint/rules/single-event-definition';
-cache['bpmnlint/start-event-required'] = rule_17;
+cache['bpmnlint/single-event-definition'] = rule_17;
-import rule_18 from 'bpmnlint/rules/sub-process-blank-start-event';
+import rule_18 from 'bpmnlint/rules/start-event-required';
-cache['bpmnlint/sub-process-blank-start-event'] = rule_18;
+cache['bpmnlint/start-event-required'] = rule_18;
-import rule_19 from 'bpmnlint/rules/superfluous-gateway';
+import rule_19 from 'bpmnlint/rules/sub-process-blank-start-event';
-cache['bpmnlint/superfluous-gateway'] = rule_19;
+cache['bpmnlint/sub-process-blank-start-event'] = rule_19;
-import rule_20 from 'bpmnlint/rules/superfluous-termination';
+import rule_20 from 'bpmnlint/rules/superfluous-gateway';
-cache['bpmnlint/superfluous-termination'] = rule_20;
+cache['bpmnlint/superfluous-gateway'] = rule_20;
-import rule_21 from 'bpmnlint-plugin-test/rules/no-label-foo';
+import rule_21 from 'bpmnlint/rules/superfluous-termination';
-cache['bpmnlint-plugin-test/no-label-foo'] = rule_21;
+cache['bpmnlint/superfluous-termination'] = rule_21;
-import rule_22 from 'bpmnlint-plugin-exported/src/foo';
+import rule_22 from 'bpmnlint-plugin-test/rules/no-label-foo';
-cache['bpmnlint-plugin-exported/foo'] = rule_22;
+cache['bpmnlint-plugin-test/no-label-foo'] = rule_22;
-import rule_23 from 'bpmnlint-plugin-exported/src/bar';
+import rule_23 from 'bpmnlint-plugin-exported/src/foo';
-cache['bpmnlint-plugin-exported/bar'] = rule_23;
+cache['bpmnlint-plugin-exported/foo'] = rule_23;
-import rule_24 from 'bpmnlint-plugin-exported/rules/baz';
+import rule_24 from 'bpmnlint-plugin-exported/src/bar';
-cache['bpmnlint-plugin-exported/baz'] = rule_24;
+cache['bpmnlint-plugin-exported/bar'] = rule_24;
-import rule_25 from 'bpmnlint-plugin-exported/src/foo';
+import rule_25 from 'bpmnlint-plugin-exported/rules/baz';
-cache['bpmnlint-plugin-exported/foo-absolute'] = rule_25;
\ No newline at end of file
+cache['bpmnlint-plugin-exported/baz'] = rule_25;
+
+import rule_26 from 'bpmnlint-plugin-exported/src/foo';
+
+cache['bpmnlint-plugin-exported/foo-absolute'] = rule_26;
\ No newline at end of file
diff --git a/test/rules/link-event.mjs b/test/rules/link-event.mjs
new file mode 100644
index 0000000..4aabf40
--- /dev/null
+++ b/test/rules/link-event.mjs
@@ -0,0 +1,82 @@
+import RuleTester from '../../lib/testers/rule-tester.js';
+
+import rule from '../../rules/link-event.js';
+
+import {
+ readModdle
+} from '../../lib/testers/helper.js';
+
+import { stubCJS } from '../helper.mjs';
+
+const {
+ __dirname
+} = stubCJS(import.meta.url);
+
+
+RuleTester.verify('link-event', rule, {
+ valid: [
+ {
+ moddleElement: readModdle(__dirname + '/link-event/valid.bpmn')
+ },
+ {
+ moddleElement: readModdle(__dirname + '/link-event/valid-collaboration.bpmn')
+ }
+ ],
+ invalid: [
+ {
+ moddleElement: readModdle(__dirname + '/link-event/invalid.bpmn'),
+ report: [
+ {
+ 'id': 'THROW_NO_NAME',
+ 'message': 'Link event is missing name',
+ 'category': 'error'
+ },
+ {
+ 'id': 'CATCH_NO_NAME',
+ 'message': 'Link event is missing name',
+ 'category': 'error'
+ },
+ {
+ 'id': 'NO_CATCH',
+ 'message': 'Link catch event with name missing in scope',
+ 'category': 'error'
+ },
+ {
+ 'id': 'NO_THROW',
+ 'message': 'Link throw event with name missing in scope',
+ 'category': 'error'
+ },
+ {
+ 'id': 'SCOPE_BOUNDARY_THROW',
+ 'message': 'Link catch event with name missing in scope',
+ 'category': 'error'
+ },
+ {
+ 'id': 'DUPLICATE_NAME_THROW_1',
+ 'message': 'Duplicate link throw event with name in scope',
+ 'category': 'error'
+ },
+ {
+ 'id': 'DUPLICATE_NAME_THROW_2',
+ 'message': 'Duplicate link throw event with name in scope',
+ 'category': 'error'
+ },
+ {
+ 'id': 'DUPLICATE_NAME_CATCH_1',
+ 'message': 'Duplicate link catch event with name in scope',
+ 'category': 'error'
+ },
+ {
+ 'id': 'DUPLICATE_NAME_CATCH_2',
+ 'message': 'Duplicate link catch event with name in scope',
+ 'category': 'error'
+ },
+ {
+ 'id': 'SCOPE_BOUNDARY_CATCH',
+ 'message': 'Link throw event with name missing in scope',
+ 'category': 'error'
+ }
+ ]
+ }
+ ]
+});
\ No newline at end of file
diff --git a/test/rules/link-event/invalid.bpmn b/test/rules/link-event/invalid.bpmn
new file mode 100644
index 0000000..e2dcdcc
--- /dev/null
+++ b/test/rules/link-event/invalid.bpmn
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/rules/link-event/valid-collaboration.bpmn b/test/rules/link-event/valid-collaboration.bpmn
new file mode 100644
index 0000000..1df013e
--- /dev/null
+++ b/test/rules/link-event/valid-collaboration.bpmn
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/rules/link-event/valid.bpmn b/test/rules/link-event/valid.bpmn
new file mode 100644
index 0000000..47ea7a1
--- /dev/null
+++ b/test/rules/link-event/valid.bpmn
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+