diff --git a/packages/cli/config/eslint-factory.js b/packages/cli/config/eslint-factory.js new file mode 100644 index 0000000000..67f30ac359 --- /dev/null +++ b/packages/cli/config/eslint-factory.js @@ -0,0 +1,286 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { join: joinPath } = require('path'); + +/** + * Creates a ESLint configuration that extends the base Backstage configuration. + * In addition to the standard ESLint configuration options, the `extraConfig` + * parameter also accepts the following keys: + * + * - `tsRules`: Additional ESLint rules to apply to TypeScript + * - `testRules`: Additional ESLint rules to apply to tests + * - `restrictedImports`: Additional paths to add to no-restricted-imports + * - `restrictedSrcImports`: Additional paths to add to no-restricted-imports in src files + * - `restrictedTestImports`: Additional paths to add to no-restricted-imports in test files + * - `restrictedSyntax`: Additional patterns to add to no-restricted-syntax + * - `restrictedSrcSyntax`: Additional patterns to add to no-restricted-syntax in src files + * - `restrictedTestSyntax`: Additional patterns to add to no-restricted-syntax in test files + */ +function createConfig(dir, extraConfig = {}) { + const { + extends: extraExtends, + plugins, + env, + parserOptions, + ignorePatterns, + + rules, + tsRules, + testRules, + + restrictedImports, + restrictedSrcImports, + restrictedTestImports, + restrictedSyntax, + restrictedSrcSyntax, + restrictedTestSyntax, + + ...otherConfig + } = extraConfig; + + return { + ...otherConfig, + extends: [ + '@spotify/eslint-config-base', + '@spotify/eslint-config-typescript', + 'prettier', + 'plugin:jest/recommended', + 'plugin:monorepo/recommended', + ...(extraExtends ?? []), + ], + parser: '@typescript-eslint/parser', + plugins: ['import', ...(plugins ?? [])], + env: { + jest: true, + ...env, + }, + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + lib: require('./tsconfig.json').compilerOptions.lib, + ...parserOptions, + }, + ignorePatterns: [ + '.eslintrc.js', + '**/dist/**', + '**/dist-types/**', + ...(ignorePatterns ?? []), + ], + rules: { + 'no-shadow': 'off', + 'no-redeclare': 'off', + '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/no-redeclare': 'error', + 'no-undef': 'off', + 'import/newline-after-import': 'error', + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: dir + ? [ + `!${joinPath(dir, 'src/**')}`, + joinPath(dir, 'src/**/*.test.*'), + joinPath(dir, 'src/**/*.stories.*'), + joinPath(dir, 'src/setupTests.*'), + ] + : [ + // Legacy config for packages that don't provide a dir + '**/*.test.*', + '**/*.stories.*', + '**/src/setupTests.*', + '**/dev/**', + ], + optionalDependencies: true, + peerDependencies: true, + bundledDependencies: true, + }, + ], + 'no-unused-expressions': 'off', + '@typescript-eslint/no-unused-expressions': 'error', + '@typescript-eslint/consistent-type-assertions': 'error', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: true, + argsIgnorePattern: '^_', + }, + ], + + 'no-restricted-imports': [ + 2, + { + paths: [ + ...(restrictedImports ?? []), + ...(restrictedSrcImports ?? []), + ], + // Avoid cross-package imports + patterns: ['**/../../**/*/src/**', '**/../../**/*/src'], + }, + ], + + 'no-restricted-syntax': [ + 'error', + ...(restrictedSyntax ?? []), + ...(restrictedSrcSyntax ?? []), + ], + + ...rules, + }, + overrides: [ + { + files: ['**/*.ts?(x)'], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + 'no-undef': 'off', + ...tsRules, + }, + }, + { + files: ['**/*.test.*', '**/*.stories.*', 'src/setupTests.*', '!src/**'], + rules: { + ...testRules, + 'no-restricted-syntax': [ + 'error', + ...(restrictedSyntax ?? []), + ...(restrictedTestSyntax ?? []), + ], + 'no-restricted-imports': [ + 2, + { + paths: [ + ...(restrictedImports ?? []), + ...(restrictedTestImports ?? []), + ], + // Avoid cross-package imports + patterns: ['**/../../**/*/src/**', '**/../../**/*/src'], + }, + ], + }, + }, + ], + }; +} + +/** + * Creates a ESLint configuration for the given package role. + * The `extraConfig` parameter accepts the same keys as for the `createConfig` function. + */ +function createConfigForRole(dir, role, extraConfig = {}) { + switch (role) { + case 'common-library': + return createConfig(dir, extraConfig); + + case 'web-library': + case 'frontend': + case 'frontend-plugin': + case 'frontend-plugin-module': + return createConfig(dir, { + ...extraConfig, + extends: [ + '@spotify/eslint-config-react', + ...(extraConfig.extends ?? []), + ], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ...extraConfig.parserOptions, + }, + settings: { + react: { + version: 'detect', + }, + ...extraConfig.settings, + }, + restrictedImports: [ + { + // Importing the entire MUI icons packages kills build performance as the list of icons is huge. + name: '@material-ui/icons', + message: "Please import '@material-ui/icons/' instead.", + }, + { + name: '@material-ui/icons/', // because this is possible too ._. + message: "Please import '@material-ui/icons/' instead.", + }, + ...require('module').builtinModules, + ...(extraConfig.restrictedImports ?? []), + ], + tsRules: { + 'react/prop-types': 0, + ...extraConfig.tsRules, + }, + }); + + case 'cli': + case 'node-library': + case 'backend': + case 'backend-plugin': + case 'backend-plugin-module': + return createConfig(dir, { + ...extraConfig, + globals: { + __non_webpack_require__: 'readonly', + ...extraConfig.globals, + }, + rules: { + 'no-console': 0, // Permitted in console programs + 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()' + ...extraConfig.rules, + }, + restrictedSyntax: [ + { + message: + 'Default import from winston is not allowed, import `* as winston` instead.', + selector: + 'ImportDeclaration[source.value="winston"] ImportDefaultSpecifier', + }, + ...(extraConfig.restrictedSyntax ?? []), + ], + restrictedSrcSyntax: [ + { + message: + "`__dirname` doesn't refer to the same dir in production builds, try `resolvePackagePath()` from `@backstage/backend-common` instead.", + selector: 'Identifier[name="__dirname"]', + }, + ...(extraConfig.restrictedSrcSyntax ?? []), + ], + }); + default: + throw new Error(`Unknown package role ${role}`); + } +} + +/** + * Creates a ESLint configuration for the package in the given directory. + * The `extraConfig` parameter accepts the same keys as for the `createConfig` function. + */ +function createPackageConfig(dir, extraConfig) { + const pkg = require(joinPath(dir, 'package.json')); + const role = pkg.backstage?.role; + if (!role) { + throw new Error(`Package ${pkg.name} does not have a backstage.role`); + } + + return createConfigForRole(dir, role, extraConfig); +} + +module.exports = createPackageConfig; // Alias +module.exports.createConfig = createConfig; +module.exports.createConfigForRole = createConfigForRole; +module.exports.createPackageConfig = createPackageConfig;