Repository: javierbrea/eslint-plugin-boundaries (js)

Issue:
Matching paths in external modules
Thanks a lot for this plugin - it has been very useful so far.
I have a question though related to the following usecase:

We are moving some elements to a shared library and would like to enforce the same rules, that we have inside the sources, e.g. from: "components", disallow: "repositories".

This is what I came up with so far - which is not working:

```
    "boundaries/external": [
      "error",
      {
        default: "allow",
        rules: [
          {
            from: ["components"],
            disallow: ["@group/package/repositories/*"],
          },
        ],
      },
    ],
```

Is there any way to specify the allowed paths in a module?

Pull request:
Release v3.2.0
- feat(#297): Support matching imported external module path in `external` rule using micromatch patterns

closes #297 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16fa82b..ec8dee3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,12 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
 ### Fixed
 ### Removed
 
+## [3.2.0] - 2023-07-30
+
+### Changed
+- feat(#297): Support matching imported external module path in `external` rule using micromatch patterns
+
 ## [3.1.1] - 2023-04-03
 
 ### Changed
 - chore(deps): Update dependencies
 
-
 ## [3.1.0] - 2022-12-06
 
 ### Added
diff --git a/docs/rules/external.md b/docs/rules/external.md
index a2c69d4..e6728c6 100644
--- a/docs/rules/external.md
+++ b/docs/rules/external.md
@@ -28,8 +28,12 @@ It checks `import` statements to external modules and allow or disallow them bas
 * `<string>`. A [`micromatch` pattern](https://github.com/micromatch/micromatch) to match the name of the module.
 * `[<string>, <object>]`. An array containing a [`micromatch` pattern](https://github.com/micromatch/micromatch) as first element, and an options object, which can have next properties:
   * `specifiers`: `<array>` Array of used specifiers when importing the library. Each specifier can be expressed also as a [`micromatch` pattern](https://github.com/micromatch/micromatch). Matching specifiers are available as `${report.specifiers}` when defining custom error messages.
+  * `path`: `<string>` or `Array<string>`. Micromatch patterns to match the path of the imported module. If the path is an array, then the module will match if any of the patterns matches. __Note that the path must start with a `/` character__. The path is available as `${report.path}` when defining custom error messages.
 
-If `specifiers` option is provided, then it will only match if any of the specifiers is used in the `import` statement.
+When using options, note that:
+
+* If `path` option is provided, then it will only match if the path of the imported module matches with any of the patterns.
+* If `specifiers` option is provided, then it will only match if any of the specifiers is used in the `import` statement.
 
 __Examples__
 
@@ -96,6 +100,8 @@ __Examples__
               "react",
               // allow importing useHistory, Switch and Route from react-router-dom
               ["react-router-dom", { "specifiers": ["useHistory", "Switch", "Route"] }],
+              // allow importing Menu icon and any icon starting with "Log" from @mui/icons-material
+              ["@mui/icons-material", { "path": ["/Menu", "/Log*"] }
             ]
           },
         ]
@@ -198,6 +204,13 @@ _Modules can't import `withRouter` from `react-router-dom`:_
 import { withRouter } from 'react-router-dom'
 ```
 
+_Modules can't import `@mui/icons-material`:_
+
+```js
+// src/modules/module-a/ModuleA.js
+import { Login } from '@mui/icons-material'
+```
+
 ### Examples of **correct** code for this rule:
 
 _Helpers can import `moment`:_
@@ -234,6 +247,20 @@ _Modules can import `useHistory` from `react-router-dom`:_
 import { useHistory } from 'react-router-dom'
 ```
 
+_Modules can import `@mui/icons-material/Menu`:_
+
+```js
+// src/modules/module-a/ModuleA.js
+import Login from '@mui/icons-material/Menu'
+```
+
+_Modules can import `@mui/icons-material/Login`:_
+
+```js
+// src/modules/module-a/ModuleA.js
+import Login from '@mui/icons-material/Login'
+```
+
 ### Error messages
 
 This rule provides a lot of information about the specific option producing an error, so the user can have enough context to solve it.
@@ -241,6 +268,7 @@ This rule provides a lot of information about the specific option producing an e
 * If the error is produced because all imports are disallowed by default, and no rule is specificly allowing it, then the message provides information about the file and the external dependency: `No rule allows the usage of external module 'react' in elements of type 'helper'`.
 * If the error is produced by a specific option, then the message includes information about the option producing it: `Usage of external module 'react' is not allowed in elements of type 'helper' with elementName 'helper-a'. Disallowed in rule 2`
 * If the error is produced by a specific option including specifiers property, then the message includes information it: `Usage of 'useMemo, useEffect' from external module 'react' is not allowed in elements of type 'helper' with elementName 'helper-a'. Disallowed in rule 2`
+* If the error is produced by a specific option including path property, then the message includes information it: `Usage of '/Login' from external module '@mui/icons-material' is not allowed in elements of type 'module' with elementName 'module-a'. Disallowed in rule 3`
 
 You can also configure a custom error message for changing this default behaviour, or even custom error messages only for a specific rule option. Read ["error messages"](../../README.md#error-messages) in the main docs for further info about how to configure messages.
 
diff --git a/package-lock.json b/package-lock.json
index 2456ec8..5551d2c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "eslint-plugin-boundaries",
-  "version": "3.1.1",
+  "version": "3.2.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "eslint-plugin-boundaries",
-      "version": "3.1.1",
+      "version": "3.2.0",
       "license": "MIT",
       "dependencies": {
         "chalk": "4.1.2",
diff --git a/package.json b/package.json
index 702950b..487b12f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-plugin-boundaries",
-  "version": "3.1.1",
+  "version": "3.2.0",
   "description": "Eslint plugin checking architecture boundaries between elements",
   "keywords": [
     "eslint",
diff --git a/sonar-project.properties b/sonar-project.properties
index 8668b82..acaed0a 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,6 +1,6 @@
 sonar.organization=javierbrea
 sonar.projectKey=javierbrea_eslint-plugin-boundaries
-sonar.projectVersion=3.1.1
+sonar.projectVersion=3.2.0
 
 sonar.javascript.file.suffixes=.js
 sonar.sourceEncoding=UTF-8
diff --git a/src/core/elementsInfo.js b/src/core/elementsInfo.js
index b46ac77..ae13e35 100644
--- a/src/core/elementsInfo.js
+++ b/src/core/elementsInfo.js
@@ -9,10 +9,7 @@ const { isArray } = require("../helpers/utils");
 
 const { filesCache, importsCache, elementsCache } = require("./cache");
 
-function baseModule(name, path) {
-  if (path) {
-    return null;
-  }
+function baseModule(name) {
   if (isScoped(name)) {
     const [scope, packageName] = name.split("/");
     return `${scope}/${packageName}`;
@@ -193,6 +190,10 @@ function projectPath(absolutePath) {
   }
 }
 
+function externalModulePath(source, baseModuleValue) {
+  return source.replace(baseModuleValue, "");
+}
+
 function importInfo(source, context) {
   const path = projectPath(resolve(source, context));
   const isExternalModule = isExternal(source, path);
@@ -205,8 +206,9 @@ function importInfo(source, context) {
     result = resultCache;
   } else {
     elementCache = elementsCache.load(path, context.settings);
+    const baseModuleValue = isExternalModule ? baseModule(source) : null;
     const isBuiltInModule = isBuiltIn(source, path);
-    const pathToUse = isExternalModule ? null : path;
+    const pathToUse = isExternalModule ? externalModulePath(source, baseModuleValue) : path;
     if (elementCache) {
       elementResult = elementCache;
     } else {
@@ -221,7 +223,7 @@ function importInfo(source, context) {
       isLocal: !isExternalModule && !isBuiltInModule,
       isBuiltIn: isBuiltInModule,
       isExternal: isExternalModule,
-      baseModule: baseModule(source, pathToUse),
+      baseModule: baseModuleValue,
       ...elementResult,
     };
 
diff --git a/src/rules/external.js b/src/rules/external.js
index 16ef756..6ed125c 100644
--- a/src/rules/external.js
+++ b/src/rules/external.js
@@ -11,14 +11,15 @@ const {
   micromatchPatternReplacingObjectsValues,
 } = require("../helpers/rules");
 const { customErrorMessage, ruleElementMessage, elementMessage } = require("../helpers/messages");
+const { isArray } = require("../helpers/utils");
 
-function specifiersMatch(specifiers, options, elementsCapturedValues) {
+function specifiersMatch(specifiers, specifierOptions, elementsCapturedValues) {
   const importedSpecifiersNames = specifiers
     .filter((specifier) => {
       return specifier.type === "ImportSpecifier" && specifier.imported.name;
     })
     .map((specifier) => specifier.imported.name);
-  return options.reduce((found, option) => {
+  return specifierOptions.reduce((found, option) => {
     const matcherWithTemplateReplaced = micromatchPatternReplacingObjectsValues(
       option,
       elementsCapturedValues
@@ -30,6 +31,23 @@ function specifiersMatch(specifiers, options, elementsCapturedValues) {
   }, []);
 }
 
+function pathMatch(path, pathOptions, elementsCapturedValues) {
+  const pathMatchers = isArray(pathOptions) ? pathOptions : [pathOptions];
+  return pathMatchers.reduce((isMatch, option) => {
+    if (isMatch) {
+      return isMatch;
+    }
+    const matcherWithTemplateReplaced = micromatchPatternReplacingObjectsValues(
+      option,
+      elementsCapturedValues
+    );
+    if (micromatch.some(path, matcherWithTemplateReplaced)) {
+      isMatch = true;
+    }
+    return isMatch;
+  }, false);
+}
+
 function isMatchExternalDependency(dependency, matcher, options, elementsCapturedValues) {
   const matcherWithTemplatesReplaced = micromatchPatternReplacingObjectsValues(
     matcher,
@@ -37,14 +55,27 @@ function isMatchExternalDependency(dependency, matcher, options, elementsCapture
   );
   const isMatch = micromatch.isMatch(dependency.baseModule, matcherWithTemplatesReplaced);
   if (isMatch && options && Object.keys(options).length) {
-    const specifiersResult = specifiersMatch(
-      dependency.specifiers,
-      options.specifiers,
-      elementsCapturedValues
-    );
+    const isPathMatch = options.path
+      ? pathMatch(dependency.path, options.path, elementsCapturedValues)
+      : true;
+    if (isPathMatch && options.specifiers) {
+      const specifiersResult = specifiersMatch(
+        dependency.specifiers,
+        options.specifiers,
+        elementsCapturedValues
+      );
+      return {
+        result: specifiersResult.length > 0,
+        report: {
+          specifiers: specifiersResult,
+        },
+      };
+    }
     return {
-      result: specifiersResult.length > 0,
-      report: specifiersResult,
+      result: isPathMatch,
+      report: {
+        path: dependency.path,
+      },
     };
   }
   return {
@@ -61,11 +92,20 @@ function elementRulesAllowExternalDependency(element, dependency, options) {
   });
 }
 
+function getErrorReportMessage(report) {
+  if (report.path) {
+    return report.path;
+  }
+  return report.specifiers.join(", ");
+}
+
 function errorMessage(ruleData, file, dependency) {
   const ruleReport = ruleData.ruleReport;
   if (ruleReport.message) {
     return customErrorMessage(ruleReport.message, file, dependency, {
-      specifiers: ruleData.report && ruleData.report.join(", "),
+      specifiers:
+        ruleData.report && ruleData.report.specifiers && ruleData.report.specifiers.join(", "),
+      path: ruleData.report && ruleData.report.path,
     });
   }
   if (ruleReport.isDefault) {
@@ -80,7 +120,7 @@ function errorMessage(ruleData, file, dependency) {
   )}. Disallowed in rule ${ruleReport.index + 1}`;
 
   if (ruleData.report) {
-    return `Usage of '${ruleData.report.join(", ")}' from external module '${
+    return `Usage of '${getErrorReportMessage(ruleData.report)}' from external module '${
       dependency.baseModule
     }' ${fileReport}`;
   }
@@ -101,6 +141,19 @@ module.exports = dependencyRule(
               type: "string",
             },
           },
+          path: {
+            oneOf: [
+              {
+                type: "string",
+              },
+              {
+                type: "array",
+                items: {
+                  type: "string",
+                },
+              },
+            ],
+          },
         },
         additionalProperties: false,
       },


Test patch:
diff --git a/test/rules/one-level/external.spec.js b/test/rules/one-level/external.spec.js
index a01a24c..999acef 100644
--- a/test/rules/one-level/external.spec.js
+++ b/test/rules/one-level/external.spec.js
@@ -43,6 +43,12 @@ const test = (settings, options, errorMessages) => {
         code: "import { withRouter } from 'react-router-dom'",
         options,
       },
+      // Modules can import react-router-dom /foo/var path
+      {
+        filename: absoluteFilePath("modules/module-a/ModuleA.js"),
+        code: "import { withRouter } from 'react-router-dom/foo/var'",
+        options,
+      },
       // Modules can import react
       {
         filename: absoluteFilePath("modules/module-a/ModuleA.js"),
@@ -272,6 +278,25 @@ const test = (settings, options, errorMessages) => {
           },
         ],
       },
+      // Modules can't import var/foo from react-router-dom
+      {
+        filename: absoluteFilePath("modules/module-a/ModuleA.js"),
+        code: "import Foo from 'react-router-dom/var/foo'",
+        options,
+        errors: [
+          {
+            message: customErrorMessage(
+              errorMessages,
+              8,
+              externalNoRuleMessage({
+                file: "'modules' with elementName 'module-a'",
+                dep: "react-router-dom",
+              })
+            ),
+            type: "ImportDeclaration",
+          },
+        ],
+      },
     ],
   });
 };
@@ -379,7 +404,11 @@ test(
         },
         {
           from: "modules",
-          disallow: ["@material-ui/core", ["react-router-dom", { specifiers: ["Link"] }]],
+          disallow: [
+            "@material-ui/core",
+            ["react-router-dom", { specifiers: ["Link"] }],
+            ["react-router-dom", { path: ["/var/*"] }],
+          ],
         },
       ],
     },
@@ -393,6 +422,7 @@ test(
     5: "Usage of 'Link' from external module 'foo-library' is not allowed in elements of type 'helpers'. Disallowed in rule 1",
     6: "Usage of 'Link, Router' from external module 'foo-library' is not allowed in elements of type 'helpers'. Disallowed in rule 1",
     7: "Usage of external module '@material-ui/core' is not allowed in elements of type 'modules'. Disallowed in rule 3",
+    8: "Usage of '/var/foo' from external module 'react-router-dom' is not allowed in elements of type 'modules'. Disallowed in rule 3",
   }
 );
 
@@ -414,7 +444,11 @@ test(
         },
         {
           from: "modules",
-          disallow: ["@material-ui/*", ["react-router-dom", { specifiers: ["Link"] }]],
+          disallow: [
+            "@material-ui/*",
+            ["react-router-dom", { specifiers: ["Link"] }],
+            ["react-router-*", { path: ["/var/foo"] }],
+          ],
         },
         {
           from: "modules",
@@ -432,6 +466,7 @@ test(
     5: "Usage of 'Link' from external module 'foo-library' is not allowed in elements of type 'helpers'. Disallowed in rule 1",
     6: "Usage of 'Link, Router' from external module 'foo-library' is not allowed in elements of type 'helpers'. Disallowed in rule 1",
     7: "Usage of external module '@material-ui/core' is not allowed in elements of type 'modules'. Disallowed in rule 3",
+    8: "Usage of '/var/foo' from external module 'react-router-dom' is not allowed in elements of type 'modules'. Disallowed in rule 3",
   }
 );
 
@@ -453,7 +488,11 @@ test(
         },
         {
           from: "m*",
-          disallow: ["@material-ui/*", ["react-router-*", { specifiers: ["L*"] }]],
+          disallow: [
+            "@material-ui/*",
+            ["react-router-*", { specifiers: ["L*"] }],
+            ["react-*", { path: ["/var/f*"] }],
+          ],
         },
         {
           from: "m*",
@@ -471,6 +510,7 @@ test(
     5: "Usage of 'L*' from external module 'foo-library' is not allowed in elements of type 'h*'. Disallowed in rule 1",
     6: "Usage of 'L*, R*' from external module 'foo-library' is not allowed in elements of type 'h*'. Disallowed in rule 1",
     7: "Usage of external module '@material-ui/core' is not allowed in elements of type 'm*'. Disallowed in rule 3",
+    8: "Usage of '/var/foo' from external module 'react-router-dom' is not allowed in elements of type 'm*'. Disallowed in rule 3",
   }
 );
 
@@ -494,7 +534,13 @@ test(
         {
           from: "modules",
           allow: ["react", "react-router-dom"],
-          disallow: [["react-router-dom", { specifiers: ["Link"] }]],
+          disallow: [["react-router-dom", { specifiers: ["Link"], path: ["*"] }]],
+        },
+        {
+          from: "modules",
+          allow: ["react", "react-router-dom"],
+          disallow: [["react-router-dom", { path: ["/var/foo", "fake"] }]],
+          message: "Do not import ${report.path} from RDD in modules",
         },
         {
           from: "modules",
@@ -509,6 +555,7 @@ test(
     4: "Usage of 'Link' from external module 'foo-library' is not allowed in elements of type 'helpers'. Disallowed in rule 1",
     5: "Usage of 'Link' from external module 'foo-library' is not allowed in elements of type 'helpers'. Disallowed in rule 1",
     6: "Usage of 'Link, Router' from external module 'foo-library' is not allowed in elements of type 'helpers'. Disallowed in rule 1",
+    8: "Do not import /var/foo from RDD in modules",
   }
 );
 
@@ -535,7 +582,10 @@ test(
         {
           from: "modules",
           allow: ["react", "react-router-dom"],
-          disallow: [["react-router-dom", { specifiers: ["Link"] }]],
+          disallow: [
+            ["react-router-dom", { specifiers: ["Link"] }],
+            ["react-router-dom", { path: "/var/foo" }],
+          ],
         },
         {
           from: "modules",
@@ -553,6 +603,7 @@ test(
     5: "Do not import Link from foo-library in helpers",
     6: "Do not import Link, Router from foo-library in helpers",
     7: "Importing @material-ui/core is not allowed in modules with name module-a",
+    8: "Importing react-router-dom/var/foo is not allowed in modules with name module-a",
   }
 );