Repository: bpmn-io/bpmnlint
Pull request #64 (2021-12-01 12:33:05)

feat: resolve local package by name
This PR ensures that rules implemented locally are also resolved locally within the package.

To do that we check for a local `package.json`. If it exists we use the package name to normalize required configs and rules.

Adjustment must be done both when resolving rules as well as when compiling the linter configuration.

----

Given this enhancements local rules can be safely referenced by name (of the package):

```
bpmnlint-plugin-foobar
-> index.js
-> .bpmnlintrc = { "extends": "plugins:foobar/recommended" }
```

---

How to try out:

```sh
git clone https://github.com/bpmn-io/bpmnlint-plugin-example
cd bpmnlint-plugin-example
echo '{ "extends": "plugin:example/recommended" }' > .bpmnlintrc
npx bpmnlint@bpmn-io/bpmnlint#local-package-resolution some-bpmn-diagram.bpmn
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7b6fa6..d24a989 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ All notable changes to [bpmnlint](https://github.com/bpmn-io/bpmnlint) are docum
 
 ___Note:__ Yet to be released changes appear here._
 
+## 7.3.0
+
+* `FEAT`: support `--version` command line flag ([#65](https://github.com/bpmn-io/bpmnlint/pull/65))
+* `FEAT`: allow rule testers to provide custom `it`
+* `FIX`: make `no-bpmndi` handle missing `LaneSet#lanes` gracefully ([#63](https://github.com/bpmn-io/bpmnlint/issues/63), [#66](https://github.com/bpmn-io/bpmnlint/pull/66))
+
 ## 7.2.1
 
 * `FIX`: exclude compensation boundary and activity in `no-disconnected` check ([#60](https://github.com/bpmn-io/bpmnlint/pull/60))
diff --git a/bin/bpmnlint.js b/bin/bpmnlint.js
index 50d4a78..1e7fce8 100755
--- a/bin/bpmnlint.js
+++ b/bin/bpmnlint.js
@@ -146,6 +146,12 @@ function errorAndExit(...args) {
   process.exit(1);
 }
 
+function showVersionAndExit() {
+  console.log(require('../package.json').version);
+
+  process.exit(0);
+}
+
 function infoAndExit(...args) {
   console.log(...args);
 
@@ -314,6 +320,7 @@ async function run() {
   const {
     help,
     init,
+    version,
     config: configOverridePath,
     _: files
   } = mri(process.argv.slice(2), {
@@ -323,6 +330,10 @@ async function run() {
     }
   });
 
+  if (version) {
+    return showVersionAndExit();
+  }
+
   if (help) {
     return infoAndExit(HELP_STRING);
   }
diff --git a/lib/resolver/node-resolver.js b/lib/resolver/node-resolver.js
index 0609450..ceb2ff1 100644
--- a/lib/resolver/node-resolver.js
+++ b/lib/resolver/node-resolver.js
@@ -9,6 +9,12 @@ function NodeResolver(options) {
   this.require = options && options.require || createScopedRequire(process.cwd());
 
   this.requireLocal = options && options.requireLocal || require;
+
+  try {
+    this.pkg = this.require('./package.json').name;
+  } catch (err) {
+    this.pkg = '__unknown';
+  }
 }
 
 module.exports = NodeResolver;
@@ -16,6 +22,10 @@ module.exports = NodeResolver;
 
 NodeResolver.prototype.resolveRule = function(pkg, ruleName) {
 
+  const originalPkg = pkg;
+
+  pkg = this.normalizePkg(pkg);
+
   try {
     if (pkg === 'bpmnlint') {
       return this.requireLocal(`../../rules/${ruleName}`);
@@ -23,12 +33,16 @@ NodeResolver.prototype.resolveRule = function(pkg, ruleName) {
       return this.require(`${pkg}/rules/${ruleName}`);
     }
   } catch (err) {
-    throw new Error('Cannot resolve rule <' + ruleName + '> from <' + pkg + '>');
+    throw new Error('Cannot resolve rule <' + ruleName + '> from <' + originalPkg + '>');
   }
 };
 
 NodeResolver.prototype.resolveConfig = function(pkg, configName) {
 
+  const originalPkg = pkg;
+
+  pkg = this.normalizePkg(pkg);
+
   // resolve config via $PKG/config/$NAME
   try {
     if (pkg === 'bpmnlint') {
@@ -53,10 +67,17 @@ NodeResolver.prototype.resolveConfig = function(pkg, configName) {
   }
 
   throw new Error(
-    'Cannot resolve config <' + configName + '> from <' + pkg + '>'
+    'Cannot resolve config <' + configName + '> from <' + originalPkg + '>'
   );
 };
 
+NodeResolver.prototype.normalizePkg = function(pkg) {
+  if (pkg !== 'bpmnlint' && pkg === this.pkg) {
+    pkg = '.';
+  }
+
+  return pkg;
+};
 
 // helpers ////////////////////
 
diff --git a/lib/support/compile-config.js b/lib/support/compile-config.js
index e673215..5a7c6ea 100644
--- a/lib/support/compile-config.js
+++ b/lib/support/compile-config.js
@@ -7,13 +7,15 @@ const NodeResolver = require('../resolver/node-resolver');
  * a { config, resolver } bundle, resolving all enabled rules.
  *
  * @param  {Object} config the parsed bpmnlint configuration
- *
+ * @param  {NodeResolver} [resolver]
  * @return {Promise<String>} the configuration compiled to a JS file
  */
-async function compileConfig(config) {
+async function compileConfig(config, resolver) {
+
+  resolver = resolver || new NodeResolver();
 
   const linter = new Linter({
-    resolver: new NodeResolver()
+    resolver
   });
 
   const resolvedRules = await linter.resolveConfiguredRules(config);
@@ -86,7 +88,7 @@ export default bundle;
     } = linter.parseRuleName(key);
 
     return `
-import rule_${idx} from '${pkg}/rules/${ruleName}';
+import rule_${idx} from '${resolver.normalizePkg(pkg)}/rules/${ruleName}';
 cache['${pkg}/${ruleName}'] = rule_${idx};`;
   }).join('\n');
 
diff --git a/package-lock.json b/package-lock.json
index eee1fac..1bd3217 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "bpmnlint",
-  "version": "7.2.1",
+  "version": "7.3.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
diff --git a/package.json b/package.json
index d214bb4..f867c72 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "bpmnlint",
   "description": "Validate your BPMN diagrams based on configurable lint rules",
-  "version": "7.2.1",
+  "version": "7.3.0",
   "main": "lib/index.js",
   "keywords": [
     "bpmnlint",
diff --git a/rules/no-bpmndi.js b/rules/no-bpmndi.js
index a9675ef..eef0aa2 100644
--- a/rules/no-bpmndi.js
+++ b/rules/no-bpmndi.js
@@ -54,6 +54,9 @@ module.exports = function() {
 function getAllBpmnElements(rootElements) {
   return flatten(rootElements.map((rootElement) => {
 
+    const laneSet =
+      rootElement.laneSets && rootElement.laneSets[0] || rootElement.childLaneSet;
+
     // Include
     // * flowElements (e.g., tasks, sequenceFlows),
     // * nested flowElements,
@@ -68,10 +71,8 @@ function getAllBpmnElements(rootElements) {
       (rootElement.flowElements && getAllBpmnElements(rootElement.flowElements.filter(hasFlowElements))) || [],
       rootElement.participants || [],
       rootElement.artifacts || [],
-      (rootElement.laneSets && rootElement.laneSets[0].lanes) || [],
-      (rootElement.laneSets && getAllBpmnElements(rootElement.laneSets[0].lanes.filter(hasChildLaneSet))) || [],
-      (rootElement.childLaneSet && rootElement.childLaneSet.lanes) || [],
-      (rootElement.childLaneSet && getAllBpmnElements(rootElement.childLaneSet.lanes.filter(hasChildLaneSet))) || []
+      laneSet && laneSet.lanes || [],
+      laneSet && laneSet.lanes && getAllBpmnElements(laneSet.lanes.filter(hasChildLaneSet)) || []
     ));
 
     if (elements.length > 0) {


Test patch:
diff --git a/lib/testers/rule-tester.js b/lib/testers/rule-tester.js
index 4b3b734..3f7a565 100644
--- a/lib/testers/rule-tester.js
+++ b/lib/testers/rule-tester.js
@@ -33,9 +33,9 @@ function verify(ruleName, rule, testCases) {
 
     describe('should lint valid', function() {
 
-      testCases.valid.forEach(({ moddleElement }, idx) => (
+      testCases.valid.forEach(({ moddleElement, it: _it }, idx) => (
 
-        it(`test case #${idx + 1}`, function() {
+        (_it || it)(`test case #${idx + 1}`, function() {
           return (
             Promise.resolve(moddleElement)
               .then(moddleRoot => {
@@ -54,9 +54,9 @@ function verify(ruleName, rule, testCases) {
 
     describe('should lint invalid', function() {
 
-      testCases.invalid.forEach(({ moddleElement, report }, idx) => (
+      testCases.invalid.forEach(({ moddleElement, report, it: _it }, idx) => (
 
-        it(`test case #${idx}`, function() {
+        (_it || it)(`test case #${idx}`, function() {
 
           if (!Array.isArray(report)) {
             report = [
diff --git a/test/integration/cli-spec.js b/test/integration/cli-spec.js
index 8d9eea2..64de21c 100644
--- a/test/integration/cli-spec.js
+++ b/test/integration/cli-spec.js
@@ -138,6 +138,16 @@ describe('cli', function() {
       }
     });
 
+
+    test({
+      cmd: [ 'bpmnlint', '--version' ],
+      expct: {
+        code: 0,
+        stderr: EMPTY,
+        stdout: require('../../package.json').version
+      }
+    });
+
   });
 
 
diff --git a/test/rules/no-bpmndi.js b/test/rules/no-bpmndi.js
index dc7b4f3..d19b8f0 100644
--- a/test/rules/no-bpmndi.js
+++ b/test/rules/no-bpmndi.js
@@ -44,6 +44,9 @@ RuleTester.verify('no-bpmndi', rule, {
     },
     {
       moddleElement: readModdle(__dirname + '/no-bpmndi/valid-empty.bpmn')
+    },
+    {
+      moddleElement: readModdle(__dirname + '/no-bpmndi/valid-no-lanes.bpmn')
     }
   ],
   invalid: [
@@ -52,7 +55,6 @@ RuleTester.verify('no-bpmndi', rule, {
       report: {
         id: 'boundaryEvent',
         message: 'Element is missing bpmndi'
-
       }
     },
     {
diff --git a/test/rules/no-bpmndi/valid-no-lanes.bpmn b/test/rules/no-bpmndi/valid-no-lanes.bpmn
new file mode 100644
index 0000000..5884e9a
--- /dev/null
+++ b/test/rules/no-bpmndi/valid-no-lanes.bpmn
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" id="sid-38422fae-e03e-43a3-bef4-bd33b32041b2" targetNamespace="http://bpmn.io/bpmn" exporter="bpmn-js (https://demo.bpmn.io)" exporterVersion="8.8.2">
+  <process id="Process_1" isExecutable="false">
+    <bpmn:laneSet id="LaneSet_1hpv2hv" />
+    <startEvent id="StartEvent_1y45yut" name="hunger noticed" />
+  </process>
+  <bpmndi:BPMNDiagram id="BpmnDiagram_1">
+    <bpmndi:BPMNPlane id="BpmnPlane_1" bpmnElement="Process_1">
+      <bpmndi:BPMNShape id="StartEvent_1y45yut_di" bpmnElement="StartEvent_1y45yut">
+        <omgdc:Bounds x="152" y="102" width="36" height="36" />
+        <bpmndi:BPMNLabel>
+          <omgdc:Bounds x="134" y="145" width="73" height="14" />
+        </bpmndi:BPMNLabel>
+      </bpmndi:BPMNShape>
+    </bpmndi:BPMNPlane>
+  </bpmndi:BPMNDiagram>
+</definitions>
diff --git a/test/spec/resolver/node-resolver-spec.js b/test/spec/resolver/node-resolver-spec.js
index 9ad27a0..78c5401 100644
--- a/test/spec/resolver/node-resolver-spec.js
+++ b/test/spec/resolver/node-resolver-spec.js
@@ -11,7 +11,14 @@ describe('resolver/node-resolver', function() {
 
     const resolver = createResolver(function(path) {
 
-      if (path === 'baz/rules/non-existing') {
+      // mimic local package look-up
+      if (path === './package.json') {
+        return {
+          name: 'bpmnlint-plugin-local'
+        };
+      }
+
+      if (path.includes('rules/non-existing')) {
         throw new Error('not found');
       }
 
@@ -65,11 +72,23 @@ describe('resolver/node-resolver', function() {
     it('should resolve external', async function() {
 
       // when
-      const resolvedRule = await resolver.resolveRule('foo', 'label-required');
+      const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-foo', 'label-required');
+
+      // then
+      expect(resolvedRule).to.eql({
+        path: 'bpmnlint-plugin-foo/rules/label-required'
+      });
+    });
+
+
+    it('should resolve local package', async function() {
+
+      // when
+      const resolvedRule = await resolver.resolveRule('bpmnlint-plugin-local', 'label-required');
 
       // then
       expect(resolvedRule).to.eql({
-        path: 'foo/rules/label-required'
+        path: './rules/label-required'
       });
     });
 
@@ -80,11 +99,31 @@ describe('resolver/node-resolver', function() {
 
       // when
       try {
-        await resolver.resolveRule('baz', 'non-existing');
+        await resolver.resolveRule('bpmnlint-plugin-baz', 'non-existing');
+      } catch (e) {
+
+        // then
+        expect(e.message).to.eql('Cannot resolve rule <non-existing> from <bpmnlint-plugin-baz>');
+
+        err = e;
+      }
+
+      // verify
+      expect(err).to.exist;
+    });
+
+
+    it('should fail to resolve local', async function() {
+
+      let err;
+
+      // when
+      try {
+        await resolver.resolveRule('bpmnlint-plugin-local', 'non-existing');
       } catch (e) {
 
         // then
-        expect(e.message).to.eql('Cannot resolve rule <non-existing> from <baz>');
+        expect(e.message).to.eql('Cannot resolve rule <non-existing> from <bpmnlint-plugin-local>');
 
         err = e;
       }
@@ -100,27 +139,29 @@ describe('resolver/node-resolver', function() {
 
     const resolver = createResolver(function(path) {
 
+      // mimic local package look-up
+      if (path === './package.json') {
+        return {
+          path,
+          name: 'bpmnlint-plugin-local'
+        };
+      }
+
       // mimic $PKG/config/$NAME resolution
-      if (path === 'bpmnlint-plugin-foo/config/bar') {
+      if (path.includes('config/bar')) {
         return {
           path,
           bar: true
         };
       }
 
-      if (path.startsWith('bpmnlint-plugin-foo/config')) {
+      // mimic embedded config not found via $PKG/config/$NAME
+      if (path.includes('config/embedded')) {
         throw new Error('not found');
       }
 
-
-      if (path.indexOf('config') !== -1) {
-        return {
-          path
-        };
-      }
-
       // mimic $PKG.configs[$NAME] resolution
-      if (path === 'bpmnlint-plugin-foo') {
+      if (path === 'bpmnlint-plugin-foo' || path === '.') {
         return {
           configs: {
             embedded: {
@@ -224,6 +265,36 @@ describe('resolver/node-resolver', function() {
     });
 
 
+    describe('should resolve local package', function() {
+
+      it('via ./config/$NAME', async function() {
+
+        // when
+        const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-local', 'bar');
+
+        // then
+        expect(resolvedConfig).to.eql({
+          path: './config/bar',
+          bar: true
+        });
+      });
+
+
+      it('via .configs[$NAME]', async function() {
+
+        // when
+        const resolvedConfig = await resolver.resolveConfig('bpmnlint-plugin-local', 'embedded');
+
+        // then
+        expect(resolvedConfig).to.eql({
+          path: '.',
+          embedded: true
+        });
+      });
+
+    });
+
+
     it('should fail to resolve external', async function() {
 
       let err;
@@ -243,6 +314,26 @@ describe('resolver/node-resolver', function() {
       expect(err).to.exist;
     });
 
+
+    it('should fail to resolve local', async function() {
+
+      let err;
+
+      // 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;
+      }
+
+      // verify
+      expect(err).to.exist;
+    });
+
   });
 
 });
diff --git a/test/spec/support/compile-config-spec.js b/test/spec/support/compile-config-spec.js
index 238d8b1..41ec19e 100644
--- a/test/spec/support/compile-config-spec.js
+++ b/test/spec/support/compile-config-spec.js
@@ -1,5 +1,7 @@
 const { expect } = require('chai');
 
+import NodeResolver from '../../../lib/resolver/node-resolver';
+
 import compileConfig from '../../../lib/support/compile-config';
 
 
@@ -45,6 +47,42 @@ describe('support/compile-config', function() {
   });
 
 
+  it('should import local', async function() {
+
+    // given
+    const resolver = new NodeResolver({
+      require: function(path) {
+
+        if (path === './package.json') {
+          return {
+            name: 'bpmnlint-plugin-local'
+          };
+        }
+
+        if (path === './config/recommended') {
+          return {
+            rules: {
+              'foo': 'error'
+            }
+          };
+        }
+
+        throw new Error('not found');
+      }
+    });
+
+
+    // when
+    const code = await compileConfig({
+      extends: 'plugin:local/recommended'
+    }, resolver);
+
+    // then
+    expect(code).to.contain('import rule_0 from \'./rules/foo\'');
+    expect(code).to.contain('cache[\'bpmnlint-plugin-local/foo\'] = rule_0');
+  });
+
+
   it('should resolve extends', async function() {
 
     // when