Repository: bpmn-io/bpmnlint
Pull request #99 (2023-03-16 12:24:36)

Resolve Explicitly Exported Plugin Rules
Gives plugin authors the freedom to choose their own plugin structure (cf. https://github.com/camunda/bpmnlint-plugin-camunda-compat/pull/87).

Example:

```javascript
module.exports = {
  rules: {
    baz: './foo/bar/baz'
  }
};
```

[Similar to ESLint](https://eslint.org/docs/latest/extend/plugins#rules-in-plugins), just supporting path references over `require` calls; require calls cannot easily be bundled for the browser, cf. https://github.com/bpmn-io/bpmnlint/pull/99#discussion_r1154061535, https://github.com/bpmn-io/bpmnlint/pull/99#issuecomment-1521983034.

---

Related to https://github.com/camunda/bpmnlint-plugin-camunda-compat/pull/87

philippfromme: @nikku I've implemented what you proposed. Please have a look.
barmac: I converted this to draft. @philippfromme feel free to mark it as ready for review when it is.
nikku: @philippfromme This looks simple enough now. Added the discussed improvements:

* https://github.com/bpmn-io/bpmnlint/pull/99/commits/8d910e5179d8f69817797662d721b8016b93736a
* https://github.com/bpmn-io/bpmnlint/pull/99/commits/b331b168d481151157d1b00f5641647fe1ffcdcf

Check it out, give me a :+1: and I'll do a final PR cleanup before merging it.
philippfromme: > Check it out, give me a 👍 and I'll do a final PR cleanup before merging it.

Let me have a quick look!
nikku: @philippfromme https://github.com/bpmn-io/bpmnlint/pull/99/commits/a20a2577ecee449ce3e476f0096864fb17ca2deb should wrap up this topic.
philippfromme: I verified it works with [bpmnlint-plugin-camunda-compat](https://github.com/camunda/bpmnlint-plugin-camunda-compat/tree/separate-c7-c8).

Patch:
diff --git a/lib/resolver/node-resolver.js b/lib/resolver/node-resolver.js
index ceb2ff1..6d42883 100644
--- a/lib/resolver/node-resolver.js
+++ b/lib/resolver/node-resolver.js
@@ -1,6 +1,8 @@
 const Module = require('module');
 const path = require('path');
 
+const { isString } = require('min-dash');
+
 /**
  * A resolver that locates rules and configurations
  * using node module resolution.
@@ -26,15 +28,48 @@ NodeResolver.prototype.resolveRule = function(pkg, ruleName) {
 
   pkg = this.normalizePkg(pkg);
 
+  let pkgInstance;
+
+  // (1) try resolving rule via $PKG.rules[$NAME]
+  try {
+    pkgInstance = this.require(pkg);
+  } catch (err) {
+
+    /* ignore */
+  }
+
+  if (pkgInstance) {
+    const rules = pkgInstance.rules || {};
+
+    if (ruleName in rules) {
+      const rule = rules[ruleName];
+
+      if (!isString(rule)) {
+        throw new Error('cannot resolve rule <' + ruleName + '> from <' + originalPkg + '>: illegal rule export (expected path reference)');
+      }
+
+      // local reference, resolved relative to pkg location
+      if (rule.startsWith('.')) {
+        const pkgDir = path.dirname(this.require.resolve(pkg));
+
+        return this.require(path.posix.normalize(`${ pkgDir }/${ rule }`));
+      }
+
+      // absolute reference
+      return this.require(rule);
+    }
+  }
+
+  // (2) try resolving rule via $PKG/rules/$NAME
   try {
     if (pkg === 'bpmnlint') {
       return this.requireLocal(`../../rules/${ruleName}`);
     } else {
       return this.require(`${pkg}/rules/${ruleName}`);
     }
-  } catch (err) {
-    throw new Error('Cannot resolve rule <' + ruleName + '> from <' + originalPkg + '>');
-  }
+  } catch (err) { /* ignore */ }
+
+  throw new Error('cannot resolve rule <' + ruleName + '> from <' + originalPkg + '>');
 };
 
 NodeResolver.prototype.resolveConfig = function(pkg, configName) {
@@ -43,16 +78,7 @@ NodeResolver.prototype.resolveConfig = function(pkg, configName) {
 
   pkg = this.normalizePkg(pkg);
 
-  // resolve config via $PKG/config/$NAME
-  try {
-    if (pkg === 'bpmnlint') {
-      return this.requireLocal(`../../config/${configName}`);
-    } else {
-      return this.require(`${pkg}/config/${configName}`);
-    }
-  } catch (err) { /* ignore */ }
-
-  // resolve config via $PKG.configs[$NAME]
+  // (1) try resolving config via $PKG.configs[$NAME]
   try {
     const instance = this.require(pkg);
 
@@ -66,8 +92,17 @@ NodeResolver.prototype.resolveConfig = function(pkg, configName) {
     /* ignore */
   }
 
+  // (2) try resolving config via $PKG/config/$NAME
+  try {
+    if (pkg === 'bpmnlint') {
+      return this.requireLocal(`../../config/${configName}`);
+    } else {
+      return this.require(`${pkg}/config/${configName}`);
+    }
+  } catch (err) { /* ignore */ }
+
   throw new Error(
-    'Cannot resolve config <' + configName + '> from <' + originalPkg + '>'
+    'cannot resolve config <' + configName + '> from <' + originalPkg + '>'
   );
 };
 
diff --git a/lib/support/compile-config.js b/lib/support/compile-config.js
index 0dbef1a..61a51ea 100644
--- a/lib/support/compile-config.js
+++ b/lib/support/compile-config.js
@@ -1,3 +1,7 @@
+const path = require('path').posix;
+
+const { isString } = require('min-dash');
+
 const Linter = require('../linter');
 
 const NodeResolver = require('../resolver/node-resolver');
@@ -53,7 +57,7 @@ Resolver.prototype.resolveRule = function(pkg, ruleName) {
   const rule = cache[pkg + '/' + ruleName];
 
   if (!rule) {
-    throw new Error('cannot resolve rule <' + pkg + '/' + ruleName + '>');
+    throw new Error('cannot resolve rule <' + pkg + '/' + ruleName + '>: not bundled');
   }
 
   return rule;
@@ -61,7 +65,7 @@ Resolver.prototype.resolveRule = function(pkg, ruleName) {
 
 Resolver.prototype.resolveConfig = function(pkg, configName) {
   throw new Error(
-    'cannot resolve config <' + configName + '> in <' + pkg +'>'
+    'cannot resolve config <' + configName + '> in <' + pkg +'>: not bundled'
   );
 };
 
@@ -93,12 +97,68 @@ export default bundle;
       pkg, ruleName
     } = linter.parseRuleName(key);
 
-    return `
-import rule_${idx} from '${resolver.normalizePkg(pkg)}/rules/${ruleName}';
-cache['${pkg}/${ruleName}'] = rule_${idx};`;
+    return createImportStatement(pkg, ruleName, idx, resolver);
   }).filter(e => e).join('\n');
 
-  return `${preambleCode}\n\n${importCode}`;
+  return `${preambleCode}${importCode}`;
 }
 
-module.exports = compileConfig;
\ No newline at end of file
+module.exports = compileConfig;
+
+function requirePkg(resolver, pkg) {
+  try {
+    return resolver.require(pkg);
+  } catch (err) {
+    return null;
+  }
+}
+
+function createImportStatement(pkg, ruleName, idx, resolver) {
+  const originalPkg = pkg;
+
+  pkg = resolver.normalizePkg(pkg);
+
+  const pkgExport = requirePkg(resolver, pkg);
+
+  // (1) try resolving rule via $PKG.rules[$NAME]
+  if (pkgExport && pkgExport.rules) {
+    const rules = pkgExport.rules;
+
+    if (ruleName in rules) {
+      const rule = rules[ruleName];
+
+      if (!isString(rule)) {
+        throw new Error(
+          `failed to bundle rule <${ruleName}> from <${ pkg }>: illegal rule export (expected path reference)`
+        );
+      }
+
+      const rulePath = computeRuleImport(resolver, pkg, rule);
+
+      return `
+import rule_${ idx } from '${rulePath}';
+
+cache['${ originalPkg }/${ ruleName }'] = rule_${ idx };`;
+
+    }
+  }
+
+  // (2) resolve rule via $PKG/rules/$NAME
+  return `
+import rule_${ idx } from '${ pkg }/rules/${ ruleName }';
+
+cache['${ originalPkg }/${ ruleName }'] = rule_${ idx };`;
+}
+
+function computeRuleImport(resolver, pkg, rule) {
+
+  // local reference, resolved relative to pkg location
+  if (rule.startsWith('.')) {
+    const pkgDir = path.dirname(resolver.require.resolve(pkg));
+
+    return path.posix.normalize(`${ pkgDir }/${ rule }`);
+  }
+
+  // absolute reference
+  return path.normalize(rule);
+}
\ No newline at end of file


Test patch:
diff --git a/test/integration/bpmnlint-plugin-exported/package.json b/test/integration/bpmnlint-plugin-exported/package.json
new file mode 100644
index 0000000..b713cb8
--- /dev/null
+++ b/test/integration/bpmnlint-plugin-exported/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "bpmnlint-plugin-exported",
+  "version": "0.0.0",
+  "description": "A test bpmnlint plug-in that exports rules",
+  "main": "src/index.js",
+  "sideEffects": false
+}
diff --git a/test/integration/bpmnlint-plugin-exported/src/bar.js b/test/integration/bpmnlint-plugin-exported/src/bar.js
new file mode 100644
index 0000000..734ab0f
--- /dev/null
+++ b/test/integration/bpmnlint-plugin-exported/src/bar.js
@@ -0,0 +1,5 @@
+module.exports = function bar() {
+  return {
+    check() {}
+  };
+};
\ No newline at end of file
diff --git a/test/integration/bpmnlint-plugin-exported/src/foo.js b/test/integration/bpmnlint-plugin-exported/src/foo.js
new file mode 100644
index 0000000..c56b468
--- /dev/null
+++ b/test/integration/bpmnlint-plugin-exported/src/foo.js
@@ -0,0 +1,5 @@
+module.exports = function foo() {
+  return {
+    check() {}
+  };
+};
\ No newline at end of file
diff --git a/test/integration/bpmnlint-plugin-exported/src/index.js b/test/integration/bpmnlint-plugin-exported/src/index.js
new file mode 100644
index 0000000..95eb006
--- /dev/null
+++ b/test/integration/bpmnlint-plugin-exported/src/index.js
@@ -0,0 +1,17 @@
+module.exports = {
+  configs: {
+    recommended: {
+      rules: {
+        'foo': 'error',
+        'bar': 'error',
+        'baz': 'error',
+        'foo-absolute': 'error'
+      }
+    }
+  },
+  rules: {
+    'foo': './foo',
+    'bar': './bar',
+    'foo-absolute': 'bpmnlint-plugin-exported/src/foo'
+  }
+};
\ No newline at end of file
diff --git a/test/integration/bundling-spec.js b/test/integration/bundling-spec.js
index e53ef2c..bb1a3eb 100644
--- a/test/integration/bundling-spec.js
+++ b/test/integration/bundling-spec.js
@@ -10,7 +10,7 @@ import { expect } from 'chai';
 
   before(function() {
 
-    this.timeout(30000);
+    this.timeout(100000);
 
     return exec('install-local', [], __dirname + '/bundling');
   });
@@ -25,7 +25,9 @@ import { expect } from 'chai';
 
 function test(bundler, options = { it: it }) {
 
-  it(`should bundle with ${bundler}`, async function() {
+  options.it(`should bundle with ${bundler}`, async function() {
+
+    this.timeout(100000);
 
     // when
     const {
@@ -38,16 +40,24 @@ function test(bundler, options = { it: it }) {
     const actualFile = path.join(__dirname, `bundling/dist/app.${bundler}.js`);
     const expectedFile = path.join(__dirname, `bundling/test/app.${bundler}.expected.js`);
 
+    const root = process.cwd().replace(/[/\\. -]+/g, '_');
+
     if (process.env.UPDATE_FIXTURES) {
-      fs.writeFileSync(expectedFile, read(actualFile), 'utf8');
+      fs.writeFileSync(expectedFile, read(actualFile).split(root).join(''), 'utf8');
     }
 
+    const actualContents = read(actualFile);
+
     // and
     expect(
-      read(actualFile), `${ actualFile } and ${ expectedFile } equal`
+      actualContents.split(root).join(''),
+      `${ actualFile } and ${ expectedFile } equal`
     ).to.eql(
-      read(expectedFile)
+      read(expectedFile).split(root).join('')
     );
+
+    expect(actualContents).not.to.include('function bar()');
+    expect(actualContents).to.include('function foo()');
   });
 
 }
diff --git a/test/integration/bundling/package.json b/test/integration/bundling/package.json
index 7e11d7b..c0bb809 100644
--- a/test/integration/bundling/package.json
+++ b/test/integration/bundling/package.json
@@ -17,6 +17,7 @@
   },
   "localDependencies": {
     "bpmnlint": "../../..",
-    "bpmnlint-plugin-test": "../bpmnlint-plugin-test"
+    "bpmnlint-plugin-test": "../bpmnlint-plugin-test",
+    "bpmnlint-plugin-exported": "../bpmnlint-plugin-exported"
   }
 }
diff --git a/test/integration/bundling/src/.bpmnlintrc b/test/integration/bundling/src/.bpmnlintrc
index 23754f4..b82a5bd 100644
--- a/test/integration/bundling/src/.bpmnlintrc
+++ b/test/integration/bundling/src/.bpmnlintrc
@@ -2,6 +2,8 @@
   "rules": {
     "label-required": 1,
     "start-event-required": "info",
-    "end-event-required": 2
+    "end-event-required": 2,
+    "bpmnlint-plugin-exported/foo": "error",
+    "bpmnlint-plugin-exported/foo-absolute": "error"
   }
 }
\ No newline at end of file
diff --git a/test/integration/bundling/test/app.rollup.expected.js b/test/integration/bundling/test/app.rollup.expected.js
index 2292f3d..d093bbe 100644
--- a/test/integration/bundling/test/app.rollup.expected.js
+++ b/test/integration/bundling/test/app.rollup.expected.js
@@ -231,6 +231,14 @@
 
   var rule_2 = /*@__PURE__*/getDefaultExportFromCjs(endEventRequired);
 
+  var foo = function foo() {
+    return {
+      check() {}
+    };
+  };
+
+  var rule_4 = /*@__PURE__*/getDefaultExportFromCjs(foo);
+
   const cache = {};
 
   /**
@@ -246,7 +254,7 @@
     const rule = cache[pkg + '/' + ruleName];
 
     if (!rule) {
-      throw new Error('cannot resolve rule <' + pkg + '/' + ruleName + '>');
+      throw new Error('cannot resolve rule <' + pkg + '/' + ruleName + '>: not bundled');
     }
 
     return rule;
@@ -254,7 +262,7 @@
 
   Resolver.prototype.resolveConfig = function(pkg, configName) {
     throw new Error(
-      'cannot resolve config <' + configName + '> in <' + pkg +'>'
+      'cannot resolve config <' + configName + '> in <' + pkg +'>: not bundled'
     );
   };
 
@@ -263,7 +271,9 @@
   const rules = {
     "label-required": 1,
     "start-event-required": "info",
-    "end-event-required": 2
+    "end-event-required": 2,
+    "exported/foo": "error",
+    "exported/foo-absolute": "error"
   };
 
   const config = {
@@ -274,10 +284,17 @@
     resolver: resolver,
     config: config
   };
+
   cache['bpmnlint/label-required'] = rule_0;
+
   cache['bpmnlint/start-event-required'] = rule_1;
+
   cache['bpmnlint/end-event-required'] = rule_2;
 
+  cache['bpmnlint-plugin-exported/foo'] = rule_4;
+
+  cache['bpmnlint-plugin-exported/foo-absolute'] = rule_4;
+
   console.log(bundle);
 
 })();
diff --git a/test/integration/bundling/test/app.webpack.expected.js b/test/integration/bundling/test/app.webpack.expected.js
index 20be072..2c24c3d 100644
--- a/test/integration/bundling/test/app.webpack.expected.js
+++ b/test/integration/bundling/test/app.webpack.expected.js
@@ -20,6 +20,8 @@ __webpack_require__.r(__webpack_exports__);
 /* harmony import */ var bpmnlint_rules_start_event_required__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(bpmnlint_rules_start_event_required__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var bpmnlint_rules_end_event_required__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! bpmnlint/rules/end-event-required */ "./node_modules/bpmnlint/rules/end-event-required.js");
 /* harmony import */ var bpmnlint_rules_end_event_required__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(bpmnlint_rules_end_event_required__WEBPACK_IMPORTED_MODULE_2__);
+/* harmony import */ var _test_integration_bundling_node_modules_bpmnlint_plugin_exported_src_foo__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! bpmnlint-plugin-exported/src/foo */ "./node_modules/bpmnlint-plugin-exported/src/foo.js");
+/* harmony import */ var _test_integration_bundling_node_modules_bpmnlint_plugin_exported_src_foo__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_test_integration_bundling_node_modules_bpmnlint_plugin_exported_src_foo__WEBPACK_IMPORTED_MODULE_3__);
 
 const cache = {};
 
@@ -36,7 +38,7 @@ Resolver.prototype.resolveRule = function(pkg, ruleName) {
   const rule = cache[pkg + '/' + ruleName];
 
   if (!rule) {
-    throw new Error('cannot resolve rule <' + pkg + '/' + ruleName + '>');
+    throw new Error('cannot resolve rule <' + pkg + '/' + ruleName + '>: not bundled');
   }
 
   return rule;
@@ -44,7 +46,7 @@ Resolver.prototype.resolveRule = function(pkg, ruleName) {
 
 Resolver.prototype.resolveConfig = function(pkg, configName) {
   throw new Error(
-    'cannot resolve config <' + configName + '> in <' + pkg +'>'
+    'cannot resolve config <' + configName + '> in <' + pkg +'>: not bundled'
   );
 };
 
@@ -53,7 +55,9 @@ const resolver = new Resolver();
 const rules = {
   "label-required": 1,
   "start-event-required": "info",
-  "end-event-required": 2
+  "end-event-required": 2,
+  "exported/foo": "error",
+  "exported/foo-absolute": "error"
 };
 
 const config = {
@@ -71,15 +75,38 @@ const bundle = {
 
 
 
-
 cache['bpmnlint/label-required'] = (bpmnlint_rules_label_required__WEBPACK_IMPORTED_MODULE_0___default());
 
 
+
 cache['bpmnlint/start-event-required'] = (bpmnlint_rules_start_event_required__WEBPACK_IMPORTED_MODULE_1___default());
 
 
+
 cache['bpmnlint/end-event-required'] = (bpmnlint_rules_end_event_required__WEBPACK_IMPORTED_MODULE_2___default());
 
+
+
+cache['bpmnlint-plugin-exported/foo'] = (_test_integration_bundling_node_modules_bpmnlint_plugin_exported_src_foo__WEBPACK_IMPORTED_MODULE_3___default());
+
+
+
+cache['bpmnlint-plugin-exported/foo-absolute'] = (_test_integration_bundling_node_modules_bpmnlint_plugin_exported_src_foo__WEBPACK_IMPORTED_MODULE_3___default());
+
+/***/ }),
+
+/***/ "./node_modules/bpmnlint-plugin-exported/src/foo.js":
+/*!**********************************************************!*\
+  !*** ./node_modules/bpmnlint-plugin-exported/src/foo.js ***!
+  \**********************************************************/
+/***/ ((module) => {
+
+module.exports = function foo() {
+  return {
+    check() {}
+  };
+};
+
 /***/ }),
 
 /***/ "./node_modules/bpmnlint-utils/dist/index.esm.js":
diff --git a/test/integration/cli-spec.js b/test/integration/cli-spec.js
index 692923d..9e92c58 100644
--- a/test/integration/cli-spec.js
+++ b/test/integration/cli-spec.js
@@ -13,7 +13,7 @@ describe('cli', function() {
 
   before(function() {
 
-    this.timeout(30000);
+    this.timeout(100000);
 
     return exec('install-local', [], __dirname + '/cli');
   });
@@ -92,6 +92,11 @@ describe('cli', function() {
     });
 
 
+    test({
+      cmd: [ 'bpmnlint', '-c', 'exported.json', 'diagram.bpmn' ]
+    });
+
+
     test({
       cmd: [ 'bpmnlint', 'complex.bpmn' ],
       expect: {
@@ -179,7 +184,7 @@ describe('cli', function() {
 
     test({
       cmd: [ 'bpmnlint', '--version' ],
-      expct: {
+      expect: {
         code: 0,
         stderr: EMPTY,
         stdout: require('../../package.json').version
@@ -193,7 +198,7 @@ describe('cli', function() {
 
     before(function() {
 
-      this.timeout(30000);
+      this.timeout(100000);
 
       return exec('install-local', [], __dirname + '/cli/child');
     });
@@ -211,7 +216,7 @@ describe('cli', function() {
 
     before(function() {
 
-      this.timeout(30000);
+      this.timeout(100000);
 
       return exec('install-local', [], __dirname + '/cli/ns');
     });
@@ -367,7 +372,7 @@ function test(options) {
 
   (only ? it.only : it)(cmd.join(' ') + (cwd ? ` (cwd: ${cwd})` : ''), async function() {
 
-    this.timeout(3000);
+    this.timeout(100000);
 
     // when
     const {
@@ -378,23 +383,23 @@ function test(options) {
       }
     });
 
+    const actualStderr = parseOutput(stdout, '---- STDERR');
+    const actualStdout = parseOutput(stdout, '---- STDOUT');
+
+    const actualCode = parseInt(
+      parseOutput(stdout, '---- CODE'), 10
+    );
+
     // then
-    if ('stderr' in expected) {
-      expectOutput(parseOutput(stdout, '---- STDERR'), expected.stderr);
+    if (expected.stderr || actualStderr) {
+      expectOutput(actualStderr, expected.stderr || '');
     }
 
-    if ('stdout' in expected) {
-      expectOutput(parseOutput(stdout, '---- STDOUT'), expected.stdout);
+    if (expected.stdout || actualStdout) {
+      expectOutput(actualStdout, expected.stdout || '');
     }
 
-
-    const code = expected.code || 0;
-
-    expect(
-      parseInt(
-        parseOutput(stdout, '---- CODE'), 10
-      )
-    ).to.eql(code);
+    expect(actualCode).to.eql(expected.code || 0);
   });
 
 }
diff --git a/test/integration/cli/exported.json b/test/integration/cli/exported.json
new file mode 100644
index 0000000..1f58edd
--- /dev/null
+++ b/test/integration/cli/exported.json
@@ -0,0 +1,6 @@
+{
+  "rules": {
+    "exported/foo": "warn",
+    "exported/foo-absolute": "warn"
+  }
+}
\ No newline at end of file
diff --git a/test/integration/cli/package.json b/test/integration/cli/package.json
index 587e92a..3d03f74 100644
--- a/test/integration/cli/package.json
+++ b/test/integration/cli/package.json
@@ -7,13 +7,15 @@
   },
   "dependencies": {
     "bpmnlint": "file:../../..",
-    "bpmnlint-plugin-test": "file:../bpmnlint-plugin-test"
+    "bpmnlint-plugin-test": "file:../bpmnlint-plugin-test",
+    "bpmnlint-plugin-exported": "file:../bpmnlint-plugin-exported"
   },
   "devDependencies": {
     "execa": "^5.1.1"
   },
   "localDependencies": {
     "bpmnlint": "../../..",
-    "bpmnlint-plugin-test": "../bpmnlint-plugin-test"
+    "bpmnlint-plugin-test": "../bpmnlint-plugin-test",
+    "bpmnlint-plugin-exported": "../bpmnlint-plugin-exported"
   }
 }
diff --git a/test/spec/resolver/node-resolver-spec.js b/test/spec/resolver/node-resolver-spec.js
index 78c5401..667cea3 100644
--- a/test/spec/resolver/node-resolver-spec.js
+++ b/test/spec/resolver/node-resolver-spec.js
@@ -9,127 +9,348 @@ describe('resolver/node-resolver', function() {
 
   describe('#resolveRule', function() {
 
-    const resolver = createResolver(function(path) {
+    const resolver = createResolver(
+      createRequire(
+        function __require(path) {
+
+          // mock local package.json resolving
+          if (path === './package.json') {
+            return {
+              name: 'bpmnlint-plugin-local'
+            };
+          }
 
-      // mimic local package look-up
-      if (path === './package.json') {
-        return {
-          name: 'bpmnlint-plugin-local'
-        };
-      }
+          /**
+           * Resolve local plugin rules.
+           */
+
+          // mock resolving of exported rules ($PKG.rules[$NAME])
+          if (path === '.') {
+            return {
+              rules: {
+                'exported-invalid': () => {},
+                'exported-path': './rules/exported-path',
+                'exported-external-path': 'some-external-library'
+              }
+            };
+          }
 
-      if (path.includes('rules/non-existing')) {
-        throw new Error('not found');
-      }
+          if (path === 'ROOT/bpmnlint-plugin-local/lib/rules/exported-path') {
+            return {
+              path
+            };
+          }
 
-      return {
-        path
-      };
-    }, function(path) {
+          // mock resolving of non-exported rules ($PKG/rules/$NAME)
+          if (path === './rules/non-exported') {
+            return {
+              path
+            };
+          }
 
-      if (path === '../../rules/non-existing') {
-        throw new Error('not found');
-      }
+          if (path === 'some-external-library') {
+            return {
+              path
+            };
+          }
 
-      return {
-        path
-      };
-    });
+          /**
+           * Resolve foreign plugin rules.
+           */
+
+          // mock resolving of exported rules ($PKG.rules[$NAME])
+          if (path === 'bpmnlint-plugin-foreign') {
+            return {
+              rules: {
+                'exported-invalid': () => {},
+                'exported-path': './rules/exported-path',
+                'exported-external-path': 'some-external-library'
+              }
+            };
+          }
 
+          if (path === 'bpmnlint-plugin-foreign/lib/rules/exported-path') {
+            return {
+              path
+            };
+          }
 
-    it('should resolve built-in', async function() {
+          // mock resolving of non-exported rules ($PKG/rules/$NAME)
+          if (path === 'bpmnlint-plugin-foreign/rules/non-exported') {
+            return {
+              path
+            };
+          }
 
-      // when
-      const resolvedRule = await resolver.resolveRule('bpmnlint', 'label-required');
+          if (path === 'ROOT/bpmnlint-plugin-foreign/lib/rules/exported-path') {
+            return {
+              path
+            };
+          }
 
-      // then
-      expect(resolvedRule).to.eql({
-        path: '../../rules/label-required'
-      });
-    });
+          throw new Error('not found: ' + path);
+        },
+        function __resolve(path) {
 
+          if (path === '.') {
+            return 'ROOT/bpmnlint-plugin-local/lib/index.js';
+          }
+
+          if (path === 'bpmnlint-plugin-foreign') {
+            return 'ROOT/bpmnlint-plugin-foreign/lib/index.js';
+          }
 
-    it('should fail to resolve built-in', async function() {
+          throw new Error('not found: ' + path);
+        }
+      ), createRequire(
+        function __require(path) {
+
+          /**
+           * Resolve built-in rules.
+           */
+
+          // mock resolving local
+          if (path === '../../rules/label-required') {
+            return {
+              path
+            };
+          }
 
-      let err;
+          throw new Error('not found: ' + path);
+        },
+        function __resolve(path) {
+          throw new Error('not found: ' + path);
+        }
+      )
+    );
 
-      // when
-      try {
-        await resolver.resolveRule('bpmnlint', 'non-existing');
-      } catch (e) {
+
+    describe('built-in', function() {
+
+      it('should resolve', async function() {
+
+        // when
+        const resolvedRule = await resolver.resolveRule('bpmnlint', 'label-required');
 
         // then
-        expect(e.message).to.eql('Cannot resolve rule <non-existing> from <bpmnlint>');
+        expect(resolvedRule).to.eql({
+          path: '../../rules/label-required'
+        });
+      });
 
-        err = e;
-      }
 
-      // verify
-      expect(err).to.exist;
+      it('should fail to resolve', async function() {
+
+        let err;
+
+        // when
+        try {
+          await resolver.resolveRule('bpmnlint', 'non-existing');
+        } catch (e) {
+          err = e;
+        }
+
+        // then
+        expect(err).to.exist;
+        expect(err.message).to.eql('cannot resolve rule <non-existing> from <bpmnlint>');
+      });
+
     });
 
 
-    it('should resolve external', async function() {
+    describe('plugin - foreign', function() {
+
+      describe('should resolve', function() {
 
-      // when
-      const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-foo', 'label-required');
+        describe('via custom path (exported through $PKG.rules[$NAME])', function() {
+
+          it('relative resolution', async function() {
+
+            // when
+            const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-foreign', 'exported-path');
+
+            // then
+            expect(resolvedRule).to.eql({
+              path: 'ROOT/bpmnlint-plugin-foreign/lib/rules/exported-path'
+            });
+          });
+
+
+          it('absolute resolution', async function() {
+
+            // when
+            const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-foreign', 'exported-external-path');
+
+            // then
+            expect(resolvedRule).to.eql({
+              path: 'some-external-library'
+            });
+          });
+
+        });
+
+
+        it('via default location ($PKG/rules/$NAME)', async function() {
+
+          // when
+          const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-foreign', 'non-exported');
+
+          // then
+          expect(resolvedRule).to.eql({
+            path: 'bpmnlint-plugin-foreign/rules/non-exported'
+          });
+        });
 
-      // then
-      expect(resolvedRule).to.eql({
-        path: 'bpmnlint-plugin-foo/rules/label-required'
       });
-    });
 
 
-    it('should resolve local package', async function() {
+      describe('should fail to resolve', function() {
+
+        it('handling non-existing rule', async function() {
 
-      // when
-      const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-local', 'label-required');
+          let err;
+
+          // when
+          try {
+            await resolver.resolveRule('bpmnlint-plugin-foreign', 'non-existing');
+          } catch (e) {
+
+            err = e;
+          }
+
+          // then
+          expect(err).to.exist;
+          expect(err.message).to.eql('cannot resolve rule <non-existing> from <bpmnlint-plugin-foreign>');
+        });
+
+
+        it('handling non-existing plugin', async function() {
+
+          let err;
+
+          // when
+          try {
+            await resolver.resolveRule('bpmnlint-plugin-non-existing', 'exported');
+          } catch (e) {
+
+            err = e;
+          }
+
+          // then
+          expect(err).to.exist;
+          expect(err.message).to.eql('cannot resolve rule <exported> from <bpmnlint-plugin-non-existing>');
+        });
+
+
+        it('handling illegal $PKG.rules[$NAME] export', async function() {
+
+          let err;
+
+          // when
+          try {
+            await resolver.resolveRule('bpmnlint-plugin-foreign', 'exported-invalid');
+          } catch (e) {
+            err = e;
+          }
+
+          // then
+          expect(err).to.exist;
+          expect(err.message).to.eql(
+            'cannot resolve rule <exported-invalid> from <bpmnlint-plugin-foreign>: illegal rule export (expected path reference)'
+          );
+        });
 
-      // then
-      expect(resolvedRule).to.eql({
-        path: './rules/label-required'
       });
+
     });
 
 
-    it('should fail to resolve external', async function() {
+    describe('plugin - local', function() {
 
-      let err;
+      describe('should resolve', function() {
 
-      // when
-      try {
-        await resolver.resolveRule('bpmnlint-plugin-baz', 'non-existing');
-      } catch (e) {
+        describe('via custom path (exported through $PKG.rules[$NAME])', function() {
 
-        // then
-        expect(e.message).to.eql('Cannot resolve rule <non-existing> from <bpmnlint-plugin-baz>');
+          it('relative resolution', async function() {
 
-        err = e;
-      }
+            // when
+            const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-local', 'exported-path');
 
-      // verify
-      expect(err).to.exist;
-    });
+            // then
+            expect(resolvedRule).to.eql({
+              path: 'ROOT/bpmnlint-plugin-local/lib/rules/exported-path'
+            });
+          });
 
 
-    it('should fail to resolve local', async function() {
+          it('absolute resolution', async function() {
 
-      let err;
+            // when
+            const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-local', 'exported-external-path');
 
-      // when
-      try {
-        await resolver.resolveRule('bpmnlint-plugin-local', 'non-existing');
-      } catch (e) {
+            // then
+            expect(resolvedRule).to.eql({
+              path: 'some-external-library'
+            });
+          });
 
-        // then
-        expect(e.message).to.eql('Cannot resolve rule <non-existing> from <bpmnlint-plugin-local>');
+        });
 
-        err = e;
-      }
 
-      // verify
-      expect(err).to.exist;
+        it('via default location ($PKG/rules/$NAME)', async function() {
+
+          // when
+          const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-local', 'non-exported');
+
+          // then
+          expect(resolvedRule).to.eql({
+            path: './rules/non-exported'
+          });
+        });
+
+      });
+
+
+      describe('should fail to resolve', function() {
+
+        it('handling non-existing rule', async function() {
+
+          let err;
+
+          // when
+          try {
+            await resolver.resolveRule('bpmnlint-plugin-local', 'non-existing');
+          } catch (e) {
+
+            err = e;
+          }
+
+          // then
+          expect(err).to.exist;
+          expect(err.message).to.eql('cannot resolve rule <non-existing> from <bpmnlint-plugin-local>');
+        });
+
+
+        it('handling illegal $PKG.rules[$NAME] export', async function() {
+
+          let err;
+
+          // when
+          try {
+            await resolver.resolveRule('bpmnlint-plugin-local', 'exported-invalid');
+          } catch (e) {
+            err = e;
+          }
+
+          // then
+          expect(err).to.exist;
+          expect(err.message).to.eql(
+            'cannot resolve rule <exported-invalid> from <bpmnlint-plugin-local>: illegal rule export (expected path reference)'
+          );
+        });
+
+      });
+
     });
 
   });
@@ -139,53 +360,76 @@ describe('resolver/node-resolver', function() {
 
     const resolver = createResolver(function(path) {
 
-      // mimic local package look-up
+      // mock local package.json resolving
       if (path === './package.json') {
         return {
-          path,
           name: 'bpmnlint-plugin-local'
         };
       }
 
-      // mimic $PKG/config/$NAME resolution
-      if (path.includes('config/bar')) {
+      /**
+       * Resolve local plugin configs.
+       */
+
+      // mock resolving of exported configs ($PKG.configs[$NAME])
+      if (path === '.') {
         return {
-          path,
-          bar: true
+          configs: {
+            exported: {
+              path: './lib/config/exported'
+            }
+          }
         };
       }
 
-      // mimic embedded config not found via $PKG/config/$NAME
-      if (path.includes('config/embedded')) {
-        throw new Error('not found');
+      // mock resolving of non-exported configs ($PKG/config/$NAME)
+      if (path === './config/non-exported') {
+        return {
+          path
+        };
       }
 
-      // mimic $PKG.configs[$NAME] resolution
-      if (path === 'bpmnlint-plugin-foo' || path === '.') {
+      /**
+       * Resolve foreign plugin configs.
+       */
+
+      // mock resolving of exported configs ($PKG.configs[$NAME])
+      if (path === 'bpmnlint-plugin-foreign') {
         return {
           configs: {
-            embedded: {
-              path,
-              embedded: true
+            exported: {
+              path: 'bpmnlint-plugin-foreign/lib/config/exported'
             }
           }
         };
       }
 
-      throw new Error('unexpected path <' + path + '>');
+      // mock resolving of non-exported configs ($PKG/config/$NAME)
+      if (path === 'bpmnlint-plugin-foreign/config/non-exported') {
+        return {
+          path
+        };
+      }
+
+      throw new Error('not found: ' + path);
     }, function(path) {
 
-      if (path === '../../config/non-existing') {
-        throw new Error('not found');
+      /**
+       * Resolve built-in configs.
+       */
+
+      // mock resolving local
+      if (path === '../../config/all' || path === '../../config/recommended') {
+        return {
+          path
+        };
       }
 
-      return {
-        path
-      };
+      throw new Error('not found: ' + path);
     });
 
 
-    describe('should resolve built-in', function() {
+    describe('built-in', function() {
 
       it('all', async function() {
 
@@ -210,128 +454,148 @@ describe('resolver/node-resolver', function() {
         });
       });
 
+
+      it('should fail to resolve', async function() {
+
+        let err;
+
+        // when
+        try {
+          await resolver.resolveConfig('bpmnlint', 'non-existing');
+        } catch (e) {
+          err = e;
+        }
+
+        // then
+        expect(err).to.exist;
+        expect(err.message).to.eql('cannot resolve config <non-existing> from <bpmnlint>');
+      });
+
     });
 
 
-    it('should fail to resolve built-in', async function() {
+    describe('plugin - foreign', function() {
 
-      let err;
+      describe('should resolve', function() {
 
-      // when
-      try {
-        await resolver.resolveConfig('bpmnlint', 'non-existing');
-      } catch (e) {
-        expect(e.message).to.eql(
-          'Cannot resolve config <non-existing> from <bpmnlint>'
-        );
+        it('exported ($PKG.configs[$NAME])', async function() {
 
-        err = e;
-      }
+          // when
+          const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-foreign', 'exported');
 
-      // verify
-      expect(err).to.exist;
-    });
+          // then
+          expect(resolvedConfig).to.eql({
+            path: 'bpmnlint-plugin-foreign/lib/config/exported'
+          });
 
+        });
 
-    describe('should resolve external', function() {
 
-      it('via $PKG/config/$NAME', async function() {
+        it('non-exported ($PKG/config/$NAME)', async function() {
 
-        // when
-        const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-foo', 'bar');
+          // when
+          const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-foreign', 'non-exported');
 
-        // then
-        expect(resolvedConfig).to.eql({
-          path: 'bpmnlint-plugin-foo/config/bar',
-          bar: true
+          // then
+          expect(resolvedConfig).to.eql({
+            path: 'bpmnlint-plugin-foreign/config/non-exported'
+          });
         });
 
       });
 
 
-      it('via $PKG.configs[$NAME]', async function() {
+      it('should fail to resolve (non-existing plugin)', async function() {
+
+        let err;
 
         // when
-        const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-foo', 'embedded');
+        try {
+          await resolver.resolveConfig('bpmnlint-plugin-non-existing', 'exported');
+        } catch (e) {
 
-        // then
-        expect(resolvedConfig).to.eql({
-          path: 'bpmnlint-plugin-foo',
-          embedded: true
-        });
+          // then
+          expect(e.message).to.eql('cannot resolve config <exported> from <bpmnlint-plugin-non-existing>');
 
-      });
+          err = e;
+        }
 
-    });
+        // verify
+        expect(err).to.exist;
+      });
 
 
-    describe('should resolve local package', function() {
+      it('should fail to resolve (non-existing config)', async function() {
 
-      it('via ./config/$NAME', async function() {
+        let err;
 
         // when
-        const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-local', 'bar');
+        try {
+          await resolver.resolveConfig('bpmnlint-plugin-foreign', 'non-existing');
+        } catch (e) {
 
-        // then
-        expect(resolvedConfig).to.eql({
-          path: './config/bar',
-          bar: true
-        });
+          // then
+          expect(e.message).to.eql('cannot resolve config <non-existing> from <bpmnlint-plugin-foreign>');
+
+          err = e;
+        }
+
+        // verify
+        expect(err).to.exist;
       });
 
+    });
 
-      it('via .configs[$NAME]', async function() {
 
-        // when
-        const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-local', 'embedded');
+    describe('plugin - local', function() {
 
-        // then
-        expect(resolvedConfig).to.eql({
-          path: '.',
-          embedded: true
+      describe('should resolve', function() {
+
+        it('exported ($PKG.configs[$NAME])', async function() {
+
+          // when
+          const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-local', 'exported');
+
+          // then
+          expect(resolvedConfig).to.eql({
+            path: './lib/config/exported'
+          });
         });
-      });
 
-    });
 
+        it('non-exported ($PKG/config/$NAME)', async function() {
 
-    it('should fail to resolve external', async function() {
+          // when
+          const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-local', 'non-exported');
 
-      let err;
+          // then
+          expect(resolvedConfig).to.eql({
+            path: './config/non-exported'
+          });
+        });
 
-      // when
-      try {
-        await resolver.resolveConfig('bpmnlint-plugin-foo', 'non-existing');
-      } catch (e) {
-        expect(e.message).to.eql(
-          'Cannot resolve config <non-existing> from <bpmnlint-plugin-foo>'
-        );
+      });
 
-        err = e;
-      }
 
-      // verify
-      expect(err).to.exist;
-    });
+      it('should fail to resolve', async function() {
 
+        let err;
 
-    it('should fail to resolve local', async function() {
+        // when
+        try {
+          await resolver.resolveConfig('bpmnlint-plugin-local', 'non-existing');
+        } catch (e) {
 
-      let err;
+          // then
+          expect(e.message).to.eql('cannot resolve config <non-existing> from <bpmnlint-plugin-local>');
 
-      // when
-      try {
-        await resolver.resolveConfig('bpmnlint-plugin-local', 'non-existing');
-      } catch (e) {
-        expect(e.message).to.eql(
-          'Cannot resolve config <non-existing> from <bpmnlint-plugin-local>'
-        );
+          err = e;
+        }
 
-        err = e;
-      }
+        // verify
+        expect(err).to.exist;
+      });
 
-      // verify
-      expect(err).to.exist;
     });
 
   });
@@ -346,4 +610,8 @@ function createResolver(require, requireLocal) {
     require,
     requireLocal
   });
+}
+
+function createRequire(require, resolve) {
+  return Object.assign(require, { resolve });
 }
\ No newline at end of file
diff --git a/test/spec/support/compile-config-spec.js b/test/spec/support/compile-config-spec.js
index 338fb0a..a150472 100644
--- a/test/spec/support/compile-config-spec.js
+++ b/test/spec/support/compile-config-spec.js
@@ -1,8 +1,8 @@
 const { expect } = require('chai');
 
-import NodeResolver from '../../../lib/resolver/node-resolver';
+const NodeResolver = require('../../../lib/resolver/node-resolver');
 
-import compileConfig from '../../../lib/support/compile-config';
+const compileConfig = require('../../../lib/support/compile-config');
 
 
 describe('support/compile-config', function() {
@@ -67,39 +67,164 @@ describe('support/compile-config', function() {
   });
 
 
-  it('should import local', async function() {
+  describe('should import custom path rules', function() {
+
+    it('through external source', async function() {
+
+      // given
+      const resolver = new NodeResolver({
+        require: createRequire(
+          function __require(path) {
+            if (path === 'bpmnlint-plugin-foreign') {
+              return {
+                configs: {
+                  recommended: {
+                    rules: {
+                      'exported-path': 'error',
+                      'exported-external-path': 'some-rule-library'
+                    }
+                  }
+                },
+                rules: {
+                  'exported-path': './rules/exported-path',
+                  'exported-external-path': 'some-rule-library'
+                }
+              };
+            }
 
-    // given
-    const resolver = new NodeResolver({
-      require: function(path) {
+            throw new Error('not found: ' + path);
+          },
+          function __resolve(path) {
 
-        if (path === './package.json') {
-          return {
-            name: 'bpmnlint-plugin-local'
-          };
-        }
+            if (path === 'bpmnlint-plugin-foreign') {
+              return 'bpmnlint-plugin-foreign/lib/index.js';
+            }
+
+
+            throw new Error('not found: ' + path);
+          }
+        )
+      });
 
-        if (path === './config/recommended') {
-          return {
-            rules: {
-              'foo': 'error'
+
+      // when
+      const code = await compileConfig({
+        extends: 'plugin:foreign/recommended'
+      }, resolver);
+
+      // then
+      expect(code).to.contain('import rule_0 from \'bpmnlint-plugin-foreign/lib/rules/exported-path\'');
+      expect(code).to.contain('cache[\'bpmnlint-plugin-foreign/exported-path\'] = rule_0');
+
+      expect(code).to.contain('import rule_1 from \'some-rule-library\'');
+      expect(code).to.contain('cache[\'bpmnlint-plugin-foreign/exported-external-path\'] = rule_1');
+    });
+
+
+    it('through local source', async function() {
+
+      // given
+      const resolver = new NodeResolver({
+        require: createRequire(
+          function __require(path) {
+            if (path === './package.json') {
+              return {
+                name: 'bpmnlint-plugin-local'
+              };
             }
-          };
-        }
 
-        throw new Error('not found');
-      }
+            if (path === '.') {
+              return {
+                configs: {
+                  recommended: {
+                    rules: {
+                      'exported-path': 'error',
+                      'exported-external-path': 'some-rule-library'
+                    }
+                  }
+                },
+                rules: {
+                  'exported-path': './rules/exported-path',
+                  'exported-external-path': 'some-rule-library'
+                }
+              };
+            }
+
+            throw new Error('not found: ' + path);
+          },
+          function __resolve(path) {
+            if (path === '.') {
+              return 'bpmnlint-plugin-local/lib/index.js';
+            }
+
+            throw new Error('not found: ' + path);
+          }
+        )
+      });
+
+
+      // when
+      const code = await compileConfig({
+        extends: 'plugin:local/recommended'
+      }, resolver);
+
+      // then
+      expect(code).to.contain('import rule_0 from \'bpmnlint-plugin-local/lib/rules/exported-path\'');
+      expect(code).to.contain('cache[\'bpmnlint-plugin-local/exported-path\'] = rule_0');
+
+      expect(code).to.contain('import rule_1 from \'some-rule-library');
+      expect(code).to.contain('cache[\'bpmnlint-plugin-local/exported-external-path\'] = rule_1');
     });
 
 
-    // when
-    const code = await compileConfig({
-      extends: 'plugin:local/recommended'
-    }, resolver);
+    it('handling illegal path errors', async function() {
+
+      // given
+      const resolver = new NodeResolver({
+        require: function(path) {
+          if (path === './package.json') {
+            return {
+              name: 'bpmnlint-plugin-local'
+            };
+          }
+
+          if (path === '.') {
+            return {
+              configs: {
+                recommended: {
+                  rules: {
+                    'not-a-path': 'error'
+                  }
+                }
+              },
+              rules: {
+                'not-a-path': () => {}
+              }
+            };
+          }
+
+          throw new Error('not found: ' + path);
+        }
+      });
+
+      let err;
+
+      // when
+      try {
+        await compileConfig({
+          extends: 'plugin:local/recommended'
+        }, resolver);
+      } catch (error) {
+        err = error;
+      }
+
+      // then
+      expect(err).to.exist;
+      expect(err.message).to.eql(
+        'failed to bundle rule <not-a-path> from <.>: illegal rule export (expected path reference)'
+      );
+    });
 
-    // then
-    expect(code).to.contain('import rule_0 from \'./rules/foo\'');
-    expect(code).to.contain('cache[\'bpmnlint-plugin-local/foo\'] = rule_0');
   });
 
 
@@ -116,4 +241,10 @@ describe('support/compile-config', function() {
     expect(code).to.contain('single-blank-start-event');
   });
 
-});
\ No newline at end of file
+});
+
+
+
+function createRequire(require, resolve) {
+  return Object.assign(require, { resolve });
+}
\ No newline at end of file