diff --git a/README.md b/README.md index c853eda..b271414 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Uncomment if will be needed - [`import-order`](./rules/import-order) - [`public-api`](./rules/public-api) - [`layers-slices`](./rules/layers-slices) +- [`layers`](./rules/layers) +- [`slices`](./rules/slices) ## Get Started diff --git a/index.js b/index.js index 4c0fbad..c003915 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,11 @@ -const path = require("path"); - module.exports = { parserOptions: { "ecmaVersion": "2015", "sourceType": "module", }, extends: [ - path.resolve(__dirname, "./rules/public-api"), - path.resolve(__dirname, "./rules/layers-slices"), - path.resolve(__dirname, "./rules/import-order") - ], + "./rules/public-api", + "./rules/layers-slices", + "./rules/import-order", + ].map(require.resolve), }; diff --git a/rules/integration.custom.js b/rules/integration.custom.js new file mode 100644 index 0000000..9405882 --- /dev/null +++ b/rules/integration.custom.js @@ -0,0 +1,330 @@ +const { ESLint } = require("eslint"); +const assert = require("assert"); +const { configLib } = require("../utils"); + +describe("Integration Custom configs tests:", () => { + + describe("With custom recommended rules: ", () => { + + const cfg = { + parserOptions: { + "ecmaVersion": "2015", + "sourceType": "module", + }, + extends: [ + "./layers-slices", + "./public-api", + "./import-order", + ].map(require.resolve), + }; + + const eslint = new ESLint({ + useEslintrc: false, + baseConfig: configLib.mockImports(cfg), + }); + + it("Custom config should lint with errors", async () => { + const report = await eslint.lintText(` + import { getSmth } from "./lib"; // import-order + import axios from "axios"; + import { data } from "../fixtures"; // import-order + import { authModel } from "entities/auth"; // import-order + import { Button } from "shared/ui"; // import-order + import { LoginForm } from "features/login-form"; // import-order + import { Header } from "widgets/header"; // import-order, import-boundaries + import { debounce } from "shared/lib/fp"; // import-order + import { AuthPage } from "pages/auth"; // import-boundaries + import { IssueDetails } from "widgets/issue-details/ui/details"; // import-order, publicAPI + `, { + filePath: "src/widgets/mock/index.js", + }); + assert.strictEqual(report[0].errorCount, 11); + }); + + it("Custom config should lint without errors", async () => { + const report = await eslint.lintText(` + import { getRoute } from "pages/auth"; + import { Header } from "widgets/header"; + import { LoginForm } from "features/login-form"; + import { Phone } from "features/login-form/phone"; + import { Article } from "entities/article"; + import { Button } from "shared/ui/button"; + import { LoginAPI } from "shared/api"; + import { model } from "../model"; + import { styles } from "./styles.module.scss"; + `, { filePath: "src/app/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 0); + }); + + it("Custom config should lint only with import-order error", async () => { + const report = await eslint.lintText(` + import { LoginAPI } from "shared/api"; + import { getRoute } from "pages/auth"; + `, { filePath: "src/app/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with layer error", async () => { + const report = await eslint.lintText(` + import { LoginForm } from "features/login-form"; + `, { filePath: "src/entities/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with slice error", async () => { + const report = await eslint.lintText(` + import { Article } from "entities/article"; + `, { filePath: "src/entities/avatar/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with PublicAPI error", async () => { + const report = await eslint.lintText(` + import { orderModel } from "entities/order/model"; + `, { filePath: "src/features/profile/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + }) + + describe("Without publicAPI: ", () => { + + const cfg = { + parserOptions: { + "ecmaVersion": "2015", + "sourceType": "module", + }, + extends: [ + "./layers-slices", + "./import-order", + ].map(require.resolve), + }; + + const eslint = new ESLint({ + useEslintrc: false, + baseConfig: configLib.mockImports(cfg), + }); + + it("Custom config should lint with errors", async () => { + const report = await eslint.lintText(` + import { getSmth } from "./lib"; // import-order + import axios from "axios"; + import { data } from "../fixtures"; // import-order + import { authModel } from "entities/auth"; // import-order + import { Button } from "shared/ui"; // import-order + import { LoginForm } from "features/login-form"; // import-order + import { Header } from "widgets/header"; // import-order, import-boundaries + import { debounce } from "shared/lib/fp"; // import-order + import { AuthPage } from "pages/auth"; // import-boundaries + `, { + filePath: "src/widgets/mock/index.js", + }); + assert.strictEqual(report[0].errorCount, 9); + }); + + it("Custom config should lint without errors", async () => { + const report = await eslint.lintText(` + import { getRoute } from "pages/auth"; + import { Header } from "widgets/header"; + import { LoginForm } from "features/login-form"; + import { Phone } from "features/login-form/phone"; + import { Article } from "entities/article"; + import { Button } from "shared/ui/button"; + import { LoginAPI } from "shared/api"; + import { model } from "../model"; + import { styles } from "./styles.module.scss"; + `, { filePath: "src/app/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 0); + }); + + it("Custom config should lint only with import-order error", async () => { + const report = await eslint.lintText(` + import { LoginAPI } from "shared/api"; + import { getRoute } from "pages/auth"; + `, { filePath: "src/app/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with layer error", async () => { + const report = await eslint.lintText(` + import { LoginForm } from "features/login-form"; + `, { filePath: "src/entities/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with slice error", async () => { + const report = await eslint.lintText(` + import { Article } from "entities/article"; + `, { filePath: "src/entities/avatar/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + }) + + describe("Without import-order: ", () => { + + const cfg = { + parserOptions: { + "ecmaVersion": "2015", + "sourceType": "module", + }, + extends: [ + "./public-api", + "./layers-slices", + ].map(require.resolve), + }; + + const eslint = new ESLint({ + useEslintrc: false, + baseConfig: configLib.mockImports(cfg), + }); + + it("Custom config should lint with errors", async () => { + const report = await eslint.lintText(` + import { getSmth } from "./lib"; + import axios from "axios"; + import { data } from "../fixtures"; + import { authModel } from "entities/auth"; + import { Button } from "shared/ui"; + import { LoginForm } from "features/login-form"; + import { Header } from "widgets/header"; // import-boundaries + import { debounce } from "shared/lib/fp"; + import { AuthPage } from "pages/auth"; // import-boundaries + import { IssueDetails } from "widgets/issue-details/ui/details"; // import-boundaries, publicAPI + `, { + filePath: "src/widgets/mock/index.js", + }); + + assert.strictEqual(report[0].errorCount, 4); + }); + + it("Custom config should lint without errors", async () => { + const report = await eslint.lintText(` + import { getRoute } from "pages/auth"; + import { Header } from "widgets/header"; + import { LoginForm } from "features/login-form"; + import { Phone } from "features/login-form/phone"; + import { Article } from "entities/article"; + import { Button } from "shared/ui/button"; + import { LoginAPI } from "shared/api"; + import { model } from "../model"; + import { styles } from "./styles.module.scss"; + `, { filePath: "src/app/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 0); + }); + + it("Custom config should lint only with layer error", async () => { + const report = await eslint.lintText(` + import { LoginForm } from "features/login-form"; + `, { filePath: "src/entities/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with slice error", async () => { + const report = await eslint.lintText(` + import { Article } from "entities/article"; + `, { filePath: "src/entities/avatar/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with PublicAPI error", async () => { + const report = await eslint.lintText(` + import { orderModel } from "entities/order/model"; + `, { filePath: "src/features/profile/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + }) + + describe("Without layers-slice, but with layers: ", () => { + + const cfg = { + parserOptions: { + "ecmaVersion": "2015", + "sourceType": "module", + }, + extends: [ + "./public-api", + "./import-order", + "./layers", + ].map(require.resolve), + }; + + const eslint = new ESLint({ + useEslintrc: false, + baseConfig: configLib.mockImports(cfg), + }); + + it("Custom config should lint with errors", async () => { + const report = await eslint.lintText(` + import { getSmth } from "./lib"; // import-order + import axios from "axios"; + import { authProcess } from "processes/auth-process"; // layers + import { data } from "../fixtures"; // import-order + import { authModel } from "entities/auth"; // import-order + import { Button } from "shared/ui"; // import-order + import { LoginForm } from "features/login-form"; // import-order + import { Header } from "widgets/header"; // import-order + import { debounce } from "shared/lib/fp"; // import-order + import { AuthPage } from "pages/auth"; // layers + import { IssueDetails } from "widgets/issue-details/ui/details"; // publicAPI + `, { + filePath: "src/widgets/mock/index.js", + }); + + assert.strictEqual(report[0].errorCount, 10); + }); + + it("Custom config should lint without errors", async () => { + const report = await eslint.lintText(` + import { getRoute } from "pages/auth"; + import { Header } from "widgets/header"; + import { LoginForm } from "features/login-form"; + import { Phone } from "features/login-form/phone"; + import { Article } from "entities/article"; + import { Button } from "shared/ui/button"; + import { LoginAPI } from "shared/api"; + import { model } from "../model"; + import { styles } from "./styles.module.scss"; + `, { filePath: "src/app/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 0); + }); + + it("Custom config should lint only with import-order error", async () => { + const report = await eslint.lintText(` + import { LoginAPI } from "shared/api"; + import { getRoute } from "pages/auth"; + `, { filePath: "src/app/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with layer error", async () => { + const report = await eslint.lintText(` + import { LoginForm } from "features/login-form"; + `, { filePath: "src/entities/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + + it("Custom config should lint only with PublicAPI error", async () => { + const report = await eslint.lintText(` + import { orderModel } from "entities/order/model"; + `, { filePath: "src/features/profile/ui/index.js" }); + + assert.strictEqual(report[0].errorCount, 1); + }); + }) +}); diff --git a/rules/integration.test.js b/rules/integration.test.js index 60f2c66..f47a356 100644 --- a/rules/integration.test.js +++ b/rules/integration.test.js @@ -24,7 +24,6 @@ describe("Integration tests:", () => { `, { filePath: "src/widgets/mock/index.js", }); - assert.strictEqual(report[0].errorCount, 11); }); diff --git a/rules/layers-slices/README.md b/rules/layers-slices/README.md index 6883784..f75fef1 100644 --- a/rules/layers-slices/README.md +++ b/rules/layers-slices/README.md @@ -19,4 +19,4 @@ import { sessionModel } from "entities/session"; import { Form, Button } from "shared/ui"; import { getAuthCtx } from "entities/session"; import { UserAvatar } from "entities/user"; -``` +``` \ No newline at end of file diff --git a/rules/layers-slices/index.js b/rules/layers-slices/index.js index 67ada13..9643566 100644 --- a/rules/layers-slices/index.js +++ b/rules/layers-slices/index.js @@ -1,13 +1,12 @@ const { layersLib } = require("../../utils"); const getLayersRules = () => - layersLib.FS_LAYERS.map((layer) => ({ from: layer, allow: layersLib.getLowerLayers(layer), })); -const getLayersBoundariesElements = () => +const getAllBoundariesElements = () => layersLib.FS_LAYERS.map((layer) => ({ type: layer, pattern: `${layer}/*`, @@ -20,7 +19,7 @@ module.exports = { extends: ["plugin:boundaries/recommended"], ignorePatterns: [".eslintrc.js"], settings: { - "boundaries/elements": getLayersBoundariesElements(), + "boundaries/elements": getAllBoundariesElements(), }, rules: { "boundaries/element-types": [ @@ -32,4 +31,4 @@ module.exports = { }, ], }, -}; +}; \ No newline at end of file diff --git a/rules/layers-slices/index.test.js b/rules/layers-slices/index.test.js new file mode 100644 index 0000000..9fb2228 --- /dev/null +++ b/rules/layers-slices/index.test.js @@ -0,0 +1,89 @@ +const { ESLint } = require("eslint"); +const assert = require("assert"); +const { configLib } = require("../../utils"); +const cfg = require("./"); + +const eslint = new ESLint({ + useEslintrc: false, + baseConfig: configLib.setParser( + configLib.mockImports(cfg), + ), +}); + +describe("Slices and Layers config:", () => { + + describe("Layers:", () => { + it("should lint with cross-import errors.", async () => { + const wrongImports = [ + `import { getRoute } from "pages/auth";`, + `import { getStore } from "app/store";`, + ]; + + const report = await eslint.lintText(wrongImports.join("\n"), { + filePath: "src/shared/lib/index.js", + }); + assert.strictEqual(report[0].errorCount, wrongImports.length); + }); + + it("should lint without errors.", async () => { + const validCodeSnippet = [ + `import { sessionModel } from "entities/session";`, + `import { Form, Button } from "shared/ui";`, + ].join("\n"); + + const report = await eslint.lintText(validCodeSnippet, { + filePath: "src/app/ui/app.js", + }); + assert.strictEqual(report[0].errorCount, 0); + }); + }); + + describe("Slices:", () => { + it("should lint with cross-import errors between pages.", async () => { + const report = await eslint.lintText(` + import { getAuthCtx } from "pages/logout"; + import { UserAvatar } from "pages/auth";`, + { filePath: "src/pages/map/index.js" }); + + assert.strictEqual(report[0].errorCount, 2); + }); + + it("should lint with cross-import errors between widgets.", async () => { + const report = await eslint.lintText(` + import { HeaderTitle } from "widgets/header"; + import { Links } from "widgets/footer";`, + { filePath: "src/widgets/mock/index.js" }); + + assert.strictEqual(report[0].errorCount, 2); + }); + + it("should lint with cross-import errors between features.", async () => { + const report = await eslint.lintText(` + import { getAuthCtx } from "features/logout"; + import { UserAvatar } from "features/viewer-picker";`, + { filePath: "src/features/auth-form/index.js" }); + + assert.strictEqual(report[0].errorCount, 2); + }); + + it("should lint with cross-import errors between entities.", async () => { + const report = await eslint.lintText(` + import { LoginForm } from "entities/login-form"; + import { Avatar } from "entities/avatar";`, + { filePath: "src/entities/mock/index.js" }); + + assert.strictEqual(report[0].errorCount, 2); + }); + + it("should lint without error", async () => { + const report = await eslint.lintText(` + import { model } from "../model"; + import { styles } from "./style.js"; + import { utils } from "../lib/utils.js";`, + { filePath: "src/entities/mock/index.js" }); + + assert.strictEqual(report[0].errorCount, 0); + }); + + }); +}); \ No newline at end of file diff --git a/rules/layers-slices/slices.test.js b/rules/layers-slices/slices.test.js deleted file mode 100644 index 88b4b98..0000000 --- a/rules/layers-slices/slices.test.js +++ /dev/null @@ -1,67 +0,0 @@ -const { ESLint } = require("eslint"); -const assert = require("assert"); -const { configLib } = require("../../utils"); -const cfg = require("."); - -const eslint = new ESLint({ - useEslintrc: false, - baseConfig: configLib.setParser( - configLib.mockImports(cfg) - ), -}); - -describe("Import boundaries between slices and layers", () => { - - it("should lint with cross-import errors between pages.", async () => { - const wrongImports = [ - `import { getAuthCtx } from "pages/logout";`, - `import { UserAvatar } from "pages/auth";`, - ]; - - const report = await eslint.lintText(wrongImports.join("\n"), { - filePath: "src/pages/map/index.js", - }); - - assert.strictEqual(report[0].errorCount, wrongImports.length); - }); - - it("should lint with cross-import errors between widgets.", async () => { - const wrongImports = [ - `import { HeaderTitle } from "widgets/header";`, - `import { Links } from "widgets/footer";`, - ]; - - const report = await eslint.lintText(wrongImports.join("\n"), { - filePath: "src/widgets/mock/index.js", - }); - - assert.strictEqual(report[0].errorCount, wrongImports.length); - }); - - it("should lint with cross-import errors between features.", async () => { - const wrongImports = [ - `import { getAuthCtx } from "features/logout";`, - `import { UserAvatar } from "features/viewer-picker";`, - ]; - - const report = await eslint.lintText(wrongImports.join("\n"), { - filePath: "features/auth-form/index.js", - }); - - assert.strictEqual(report[0].errorCount, wrongImports.length); - }); - - it("should lint with cross-import errors between entities.", async () => { - const wrongImports = [ - `import { LoginForm } from "features/login-form";`, - `import { Avatar } from "features/avatar";`, - ]; - - const report = await eslint.lintText(wrongImports.join("\n"), { - filePath: "src/entities/mock/index.js", - }); - - assert.strictEqual(report[0].errorCount, wrongImports.length); - }); - -}); diff --git a/rules/layers/index.js b/rules/layers/index.js new file mode 100644 index 0000000..d70d783 --- /dev/null +++ b/rules/layers/index.js @@ -0,0 +1,34 @@ +const { layersLib } = require("../../utils"); + +const getLayersRules = () => + layersLib.FS_LAYERS.map((layer) => ({ + from: layer, + disallow: layersLib.getUpperLayers(layer), + })); + +const getLayersBoundariesElements = () => + layersLib.FS_LAYERS.map((layer) => ({ + type: layer, + pattern: `${layer}/**`, + mode: "folder", + capture: ["layers"], + })); + +module.exports = { + plugins: ["boundaries"], + extends: ["plugin:boundaries/recommended"], + ignorePatterns: [".eslintrc.js"], + settings: { + "boundaries/elements": getLayersBoundariesElements(), + }, + rules: { + "boundaries/element-types": [ + 2, + { + "default": "allow", + "message": "\"${file.type}\" is not allowed to import \"${dependency.type}\" | See rules: https://feature-sliced.design/docs/reference/layers/overview ", + "rules": getLayersRules(), + }, + ], + }, +}; diff --git a/rules/layers/index.md b/rules/layers/index.md new file mode 100644 index 0000000..743f84f --- /dev/null +++ b/rules/layers/index.md @@ -0,0 +1,19 @@ +# @feature-sliced/layers + +#### Reference: [Layers](https://feature-sliced.design/docs/reference/layers/overview) + +#### Usage: +Add `"@feature-sliced/eslint-config/rules/layers"` to you `extends` section in ESLint config. + +```js +// 👎 Fail +// 🛣 features/auth-form/index.ts +import { getRoute } from "pages/auth"; +import { getStore } from "app/store"; + + +// 👍 Pass +// 🛣 features/auth-form/index.ts +import { Form, Button } from "shared/ui"; +import { getAuthCtx } from "entities/session"; +``` diff --git a/rules/layers-slices/layers.test.js b/rules/layers/index.test.js similarity index 100% rename from rules/layers-slices/layers.test.js rename to rules/layers/index.test.js diff --git a/rules/slices/index.js b/rules/slices/index.js new file mode 100644 index 0000000..e95fc68 --- /dev/null +++ b/rules/slices/index.js @@ -0,0 +1,34 @@ +const { layersLib } = require("../../utils"); + +const getSlicesRules = () => + layersLib.FS_LAYERS.map((layer) => ({ + from: layer, + disallow: layer, + })); + +const getSlicesBoundariesElements = () => + layersLib.FS_LAYERS.map((layer) => ({ + type: layer, + pattern: `${layer}`, + mode: "folder", + capture: ["slices"], + })); + +module.exports = { + plugins: ["boundaries"], + extends: ["plugin:boundaries/recommended"], + ignorePatterns: [".eslintrc.js"], + settings: { + "boundaries/elements": getSlicesBoundariesElements(), + }, + rules: { + "boundaries/element-types": [ + 2, + { + "default": "allow", + "message": "\"${file.type}\" is not allowed to import \"${dependency.type}\" | See rules: https://feature-sliced.design/docs/concepts/app-splitting#group-slices ", + "rules": getSlicesRules(), + }, + ], + }, +}; diff --git a/rules/slices/index.md b/rules/slices/index.md new file mode 100644 index 0000000..3315c40 --- /dev/null +++ b/rules/slices/index.md @@ -0,0 +1,18 @@ +# @feature-sliced/slices + +#### Reference: [Cross-communication](https://feature-sliced.design/docs/concepts/cross-communication) + +#### Usage: +Add `"@feature-sliced/eslint-config/rules/slices"` to you `extends` section in ESLint config. + +```js +// 👎 Fail +// 🛣 features/auth-form/index.ts +import { getAuthCtx } from "features/logout"; +import { UserAvatar } from "features/viewer-picker"; + +// 👍 Pass +// 🛣 features/auth-form/index.ts +import { sessionModel } from "entities/session"; +import { Form, Button } from "shared/ui"; +``` diff --git a/rules/slices/index.test.js b/rules/slices/index.test.js new file mode 100644 index 0000000..7926cde --- /dev/null +++ b/rules/slices/index.test.js @@ -0,0 +1,61 @@ +const { ESLint } = require("eslint"); +const assert = require("assert"); +const { configLib } = require("../../utils"); +const cfg = require("./"); + +const eslint = new ESLint({ + useEslintrc: false, + baseConfig: configLib.setParser( + configLib.mockImports(cfg), + ), +}); + +describe("Import boundaries between slices", () => { + + it("should lint with cross-import errors between pages.", async () => { + const report = await eslint.lintText(` + import { getAuthCtx } from "pages/logout"; + import { UserAvatar } from "pages/auth";`, + { filePath: "src/pages/map/index.js" }); + + assert.strictEqual(report[0].errorCount, 2); + }); + + it("should lint with cross-import errors between widgets.", async () => { + const report = await eslint.lintText(` + import { HeaderTitle } from "widgets/header"; + import { Links } from "widgets/footer";`, + { filePath: "src/widgets/mock/index.js" }); + + assert.strictEqual(report[0].errorCount, 2); + }); + + it("should lint with cross-import errors between features.", async () => { + const report = await eslint.lintText(` + import { getAuthCtx } from "features/logout"; + import { UserAvatar } from "features/viewer-picker";`, + { filePath: "src/features/auth-form/index.js" }); + + assert.strictEqual(report[0].errorCount, 2); + }); + + it("should lint with cross-import errors between entities.", async () => { + const report = await eslint.lintText(` + import { LoginForm } from "entities/login-form"; + import { Avatar } from "entities/avatar";`, + { filePath: "src/entities/mock/index.js" }); + + assert.strictEqual(report[0].errorCount, 2); + }); + + it("should lint without error", async () => { + const report = await eslint.lintText(` + import { model } from "../model"; + import { styles } from "./style.js"; + import { utils } from "../lib/utils.js";`, + { filePath: "src/entities/mock/index.js" }); + + assert.strictEqual(report[0].errorCount, 0); + }); + +}); diff --git a/utils/config/index.js b/utils/config/index.js index 86e6979..7532875 100644 --- a/utils/config/index.js +++ b/utils/config/index.js @@ -10,9 +10,9 @@ const mockImports = (config, extension = "js") => { extension, }, }, - } - } -} + }, + }; +}; function setParser (config, version = "2015") { return { @@ -31,4 +31,8 @@ function setTSParser (config) { }; } -module.exports.configLib = { mockImports, setParser, setTSParser }; +module.exports.configLib = { + mockImports, + setParser, + setTSParser, +};