Files
explore/eslint.config.mjs
tom goriunovandGitHub d451ff9d30 [Migration 0-2] Enable ESLint import/no-cycle + boundaries rules (#3349)
Enable ESLint import/no-cycle and architecture boundary rules

Add eslint-plugin-boundaries with element-types matching ARCH_REDESIGN §8,
enable import/no-cycle (error in client/, warn in lib/ and ui/), extend
consistent-default-export-name to client/**/*.tsx, and document §10
types/api.ts enforcement as a follow-up.
2026-03-02 10:54:15 +02:00

602 lines
18 KiB
JavaScript

import { includeIgnoreFile } from '@eslint/compat';
import jsPlugin from '@eslint/js';
import nextJsPlugin from '@next/eslint-plugin-next';
import stylisticPlugin from '@stylistic/eslint-plugin';
import reactQueryPlugin from '@tanstack/eslint-plugin-query';
import boundariesPlugin from 'eslint-plugin-boundaries';
import consistentDefaultExportNamePlugin from 'eslint-plugin-consistent-default-export-name';
import importPlugin from 'eslint-plugin-import';
import importHelpersPlugin from 'eslint-plugin-import-helpers';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import playwrightPlugin from 'eslint-plugin-playwright';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import * as regexpPlugin from 'eslint-plugin-regexp';
import vitestPlugin from 'eslint-plugin-vitest';
import globals from 'globals';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import tseslint from 'typescript-eslint';
const RESTRICTED_MODULES = {
paths: [
{ name: 'dayjs', message: 'Please use lib/date/dayjs.ts instead of directly importing dayjs' },
{ name: '@chakra-ui/icons', message: 'Using @chakra-ui/icons is prohibited. Please use regular svg-icon instead (see examples in "icons/" folder)' },
{ name: '@metamask/providers', message: 'Please lazy-load @metamask/providers or use useProvider hook instead' },
{ name: '@metamask/post-message-stream', message: 'Please lazy-load @metamask/post-message-stream or use useProvider hook instead' },
{ name: 'playwright/TestApp', message: 'Please use render() fixture from test() function of playwright/lib module' },
{
name: '@chakra-ui/react',
importNames: [
'Menu', 'useToast', 'useDisclosure', 'useClipboard', 'Tooltip', 'Skeleton', 'IconButton', 'Button', 'ButtonGroup', 'Link', 'LinkBox', 'LinkOverlay',
'Dialog', 'DialogRoot', 'DialogContent', 'DialogHeader', 'DialogCloseTrigger', 'DialogBody',
'Tag', 'Switch', 'Image', 'Popover', 'PopoverTrigger', 'PopoverContent', 'PopoverBody', 'PopoverFooter',
'DrawerRoot', 'DrawerBody', 'DrawerContent', 'DrawerOverlay', 'DrawerBackdrop', 'DrawerTrigger', 'Drawer',
'Alert', 'AlertIcon', 'AlertTitle', 'AlertDescription',
'Select', 'SelectRoot', 'SelectControl', 'SelectContent', 'SelectItem', 'SelectValueText',
'Heading', 'Badge', 'Tabs', 'Show', 'Hide', 'Checkbox', 'CheckboxGroup',
'Table', 'TableRoot', 'TableBody', 'TableHeader', 'TableRow', 'TableCell',
'Menu', 'MenuRoot', 'MenuTrigger', 'MenuContent', 'MenuItem', 'MenuTriggerItem', 'MenuRadioItemGroup', 'MenuContextTrigger',
'Rating', 'RatingGroup', 'Textarea', 'Progress', 'ProgressCircle',
'EmptyState',
],
message: 'Please use corresponding component or hook from "toolkit" instead',
},
{
name: 'next/link',
importNames: [ 'default' ],
message: 'Please use toolkit/chakra/link component instead',
},
],
patterns: [
'icons/*',
],
};
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const gitignorePath = path.resolve(__dirname, '.gitignore');
/** @see client/ARCH_REDESIGN.md §8 — dependency layers for migrated code */
const ARCH_BOUNDARY_ELEMENTS = [
{ type: 'client-api', pattern: 'client/api/**', mode: 'full' },
{ type: 'client-slices', pattern: 'client/slices/**', mode: 'full' },
{ type: 'client-features', pattern: 'client/features/**', mode: 'full' },
{ type: 'client-shared', pattern: 'client/shared/**', mode: 'full' },
{ type: 'client-shell', pattern: 'client/shell/**', mode: 'full' },
{ type: 'configs', pattern: 'configs/**', mode: 'full' },
{ type: 'toolkit', pattern: 'toolkit/**', mode: 'full' },
{ type: 'nextjs', pattern: 'nextjs/**', mode: 'full' },
{ type: 'legacy-lib', pattern: 'lib/**', mode: 'full' },
{ type: 'legacy-ui', pattern: 'ui/**', mode: 'full' },
];
/** @type {import('eslint').Linter.Config[]} */
export default tseslint.config(
includeIgnoreFile(gitignorePath),
{ files: [ '**/*.{js,mjs,cjs,ts,jsx,tsx}', '**/*.pw.tsx' ] },
{ ignores: [
'deploy/tools/',
'public/',
'.git/',
'next.config.js',
] },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
{
settings: {
react: { version: 'detect' },
'boundaries/elements': ARCH_BOUNDARY_ELEMENTS,
},
},
jsPlugin.configs.recommended,
{
plugins: {
'@typescript-eslint': tseslint.plugin,
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
},
},
rules: {
'@typescript-eslint/array-type': [ 'error', {
'default': 'generic',
readonly: 'generic',
} ],
'@typescript-eslint/consistent-type-imports': [ 'error' ],
'@typescript-eslint/naming-convention': [ 'error',
{
selector: 'default',
format: [ 'camelCase' ],
leadingUnderscore: 'allow',
trailingUnderscore: 'forbid',
},
{
selector: 'import',
leadingUnderscore: 'allow',
format: [ 'camelCase', 'PascalCase' ],
},
{
selector: 'class',
format: [ 'PascalCase' ],
},
{
selector: 'enum',
format: [ 'PascalCase', 'UPPER_CASE' ],
},
{
selector: 'enumMember',
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
},
{
selector: 'function',
format: [ 'camelCase', 'PascalCase' ],
},
{
selector: 'interface',
format: [ 'PascalCase' ],
},
{
selector: 'method',
format: [ 'camelCase', 'snake_case', 'UPPER_CASE' ],
leadingUnderscore: 'allow',
},
{
selector: 'parameter',
format: [ 'camelCase', 'PascalCase' ],
leadingUnderscore: 'allow',
},
{
selector: 'property',
format: null,
},
{
selector: 'typeAlias',
format: [ 'PascalCase' ],
},
{
selector: 'typeLike',
format: [ 'PascalCase' ],
},
{
selector: 'typeParameter',
format: [ 'PascalCase', 'UPPER_CASE' ],
},
{
selector: 'variable',
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
leadingUnderscore: 'allow',
},
],
'@typescript-eslint/no-empty-function': [ 'off' ],
'@typescript-eslint/no-unused-vars': [ 'error', { caughtErrors: 'none', ignoreRestSiblings: true } ],
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/no-useless-constructor': [ 'error' ],
'@typescript-eslint/no-explicit-any': [ 'error', { ignoreRestArgs: true } ],
'@typescript-eslint/no-unused-expressions': [ 'error', {
allowShortCircuit: true,
allowTernary: true,
} ],
},
},
{
// disable type-aware linting on JS files
files: [ '**/*.{js,mjs}' ],
...tseslint.configs.disableTypeChecked,
},
{
plugins: {
react: reactPlugin,
},
rules: {
'react/jsx-key': 'error',
'react/jsx-no-bind': [ 'error', {
ignoreRefs: true,
} ],
'react/jsx-curly-brace-presence': [ 'error', {
props: 'never',
children: 'never',
} ],
'react/jsx-curly-spacing': [ 'error', {
when: 'always',
children: true,
spacing: {
objectLiterals: 'never',
},
} ],
'react/jsx-equals-spacing': [ 'error', 'never' ],
'react/jsx-fragments': [ 'error', 'syntax' ],
'react/jsx-no-duplicate-props': 'error',
'react/jsx-no-target-blank': 'off',
'react/jsx-no-useless-fragment': 'error',
'react/jsx-tag-spacing': [ 'error', {
afterOpening: 'never',
beforeSelfClosing: 'never',
closingSlash: 'never',
} ],
'react/jsx-wrap-multilines': [ 'error', {
declaration: 'parens-new-line',
assignment: 'parens-new-line',
'return': 'parens-new-line',
arrow: 'parens-new-line',
condition: 'parens-new-line',
logical: 'parens-new-line',
prop: 'parens-new-line',
} ],
'react/no-access-state-in-setstate': 'error',
'react/no-deprecated': 'error',
'react/no-direct-mutation-state': 'error',
'react/no-find-dom-node': 'off',
'react/no-redundant-should-component-update': 'error',
'react/no-render-return-value': 'error',
'react/no-string-refs': 'off',
'react/no-unknown-property': 'error',
'react/no-unused-state': 'error',
'react/require-optimization': [ 'error' ],
'react/void-dom-elements-no-children': 'error',
},
},
{
plugins: {
'@next/next': nextJsPlugin,
},
rules: {
...nextJsPlugin.configs.recommended.rules,
...nextJsPlugin.configs['core-web-vitals'].rules,
},
},
{
plugins: { '@tanstack/query': reactQueryPlugin },
},
{
...playwrightPlugin.configs['flat/recommended'],
files: [ '**/*.pw.tsx' ],
rules: {
...playwrightPlugin.configs['flat/recommended'].rules,
'playwright/no-standalone-expect': 'off', // this rules does not work correctly with extended test functions
},
},
{
plugins: { 'react-hooks': reactHooksPlugin },
ignores: [ '**/*.pw.tsx', 'playwright/**' ],
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
},
},
{
files: [ '**/*.spec.{ts,js,jsx,tsx}' ],
plugins: { vitest: vitestPlugin },
rules: {
...vitestPlugin.configs.recommended.rules,
},
settings: {
vitest: {
typecheck: true,
},
},
languageOptions: {
globals: {
...vitestPlugin.environments.env.globals,
fetchMock: true,
},
},
},
regexpPlugin.configs['flat/recommended'],
{
plugins: {
'import': importPlugin,
},
rules: {
'import/no-duplicates': 'error',
},
},
{
files: [ 'client/**' ],
plugins: {
'import': importPlugin,
},
rules: {
'import/no-cycle': [ 'error', { maxDepth: 10 } ],
},
},
{
files: [ 'lib/**', 'ui/**' ],
plugins: {
'import': importPlugin,
},
rules: {
'import/no-cycle': 'warn',
},
},
/*
* ARCH_REDESIGN.md §10 — public type surface: consumers should import slice/feature types only from
* `types/api.ts`. eslint-plugin-boundaries can enforce this with `capture` on element patterns and
* targeted `disallow` rules; that is left for a follow-up once more slices/features exist under client/.
* Type-only cross-layer imports are approximated via per-rule `importKind: 'type'` (v4 has no `allowTypeImports` option).
*/
{
files: [
'client/**/*.{ts,tsx}',
'configs/**/*.{ts,tsx,mjs}',
],
plugins: {
boundaries: boundariesPlugin,
},
rules: {
'boundaries/element-types': [ 'error', {
'default': 'disallow',
rules: [
{
from: 'client-api',
allow: [ 'client-shared', 'legacy-lib' ],
},
{
from: 'client-api',
allow: [ 'client-slices', 'client-features' ],
importKind: 'type',
},
{
from: 'client-slices',
allow: [ 'client-api', 'client-shared', 'client-slices', 'legacy-lib', 'legacy-ui', 'toolkit', 'configs' ],
},
{
from: 'client-slices',
allow: [ 'client-features' ],
importKind: 'type',
},
{
from: 'client-features',
allow: [ 'client-api', 'client-shared', 'client-slices', 'client-features', 'legacy-lib', 'legacy-ui', 'toolkit', 'configs' ],
},
{
from: 'client-shared',
allow: [ 'client-shared', 'legacy-lib', 'legacy-ui', 'toolkit', 'configs' ],
},
{
from: 'client-shared',
allow: [ 'client-slices' ],
importKind: 'type',
},
{
from: 'client-shell',
allow: [
'client-api',
'client-slices',
'client-features',
'client-shared',
'client-shell',
'legacy-lib',
'legacy-ui',
'toolkit',
'nextjs',
'configs',
],
},
{
from: 'configs',
// `configs` must not import `client/*`; `lib/*` stays allowed until config-owned types move out of legacy paths
allow: [ 'configs', 'legacy-lib' ],
},
],
} ],
},
},
{
plugins: {
'import-helpers': importHelpersPlugin,
},
rules: {
'import-helpers/order-imports': [
'error',
{
newlinesBetween: 'always',
groups: [
'module',
'/types/',
[ '/^nextjs/' ],
[
'/^client/',
'/^configs/',
'/^data/',
'/^deploy/',
'/^icons/',
'/^lib/',
'/^mocks/',
'/^pages/',
'/^playwright/',
'/^stubs/',
'/^theme/',
'/^toolkit/',
'/^ui/',
'/^vitest/',
],
[ 'parent', 'sibling', 'index' ],
],
alphabetize: { order: 'asc', ignoreCase: true },
},
],
},
},
{
plugins: {
'jsx-a11y': jsxA11yPlugin,
},
languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
},
{
plugins: {
'consistent-default-export-name': consistentDefaultExportNamePlugin,
},
files: [
'ui/**/[A-Z]*.tsx',
'client/**/*.tsx',
],
ignores: [
'**/*.pw.*',
'**/*.pwstory.*',
'**/pages/**',
],
rules: {
'consistent-default-export-name/default-export-match-filename': [ 'error', null ],
},
},
{
plugins: {
'@stylistic': stylisticPlugin,
},
rules: {
// replacement for @typescript-eslint
'@stylistic/indent': [ 'error', 2 ],
'@stylistic/brace-style': [ 'error', '1tbs' ],
'@stylistic/member-delimiter-style': [ 'error' ],
'@stylistic/type-annotation-spacing': 'error',
// replacement for eslint
'@stylistic/array-bracket-spacing': [ 'error', 'always' ],
'@stylistic/arrow-spacing': [ 'error', { before: true, after: true } ],
'@stylistic/comma-dangle': [ 'error', 'always-multiline' ],
'@stylistic/comma-spacing': [ 'error' ],
'@stylistic/comma-style': [ 'error', 'last' ],
'@stylistic/curly-newline': [ 'error', { multiline: true, minElements: 1 } ],
'@stylistic/eol-last': 'error',
'@stylistic/jsx-quotes': [ 'error', 'prefer-double' ],
'@stylistic/key-spacing': [ 'error', {
beforeColon: false,
afterColon: true,
} ],
'@stylistic/keyword-spacing': 'error',
'@stylistic/linebreak-style': [ 'error', 'unix' ],
'@stylistic/lines-around-comment': [ 'error', {
beforeBlockComment: true,
allowBlockStart: true,
} ],
'@stylistic/no-mixed-operators': [ 'error', {
groups: [
[ '&&', '||' ],
],
} ],
'@stylistic/no-mixed-spaces-and-tabs': 'error',
'@stylistic/no-multiple-empty-lines': [ 'error', {
max: 1,
maxEOF: 0,
maxBOF: 0,
} ],
'@stylistic/no-multi-spaces': 'error',
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/object-curly-spacing': [ 'error', 'always' ],
'@stylistic/operator-linebreak': [ 'error', 'after' ],
'@stylistic/quote-props': [ 'error', 'as-needed', {
keywords: true,
numbers: true,
} ],
'@stylistic/quotes': [ 'error', 'single', {
allowTemplateLiterals: 'always',
} ],
'@stylistic/semi': [ 'error', 'always' ],
'@stylistic/space-before-function-paren': [ 'error', { anonymous: 'never', named: 'never', asyncArrow: 'never', 'catch': 'always' } ],
'@stylistic/space-before-blocks': [ 'error', 'always' ],
'@stylistic/space-in-parens': [ 'error', 'never' ],
'@stylistic/space-infix-ops': 'error',
'@stylistic/space-unary-ops': 'off',
'@stylistic/template-curly-spacing': [ 'error', 'always' ],
'@stylistic/wrap-iife': [ 'error', 'inside' ],
},
ignores: [
'next-env.d.ts',
],
},
{
rules: {
// disabled in favor of @typescript-eslint and @stylistic
'no-use-before-define': 'off',
'no-useless-constructor': 'off',
'no-unused-vars': 'off',
'no-empty': [ 'error', { allowEmptyCatch: true } ],
'no-unused-expressions': 'off',
// this is checked by typescript compiler
'no-redeclare': 'off',
// rules customizations
eqeqeq: [ 'error', 'allow-null' ],
'id-match': [ 'error', '^[\\w$]+$' ],
'max-len': [ 'error', 160, 4 ],
'no-console': 'error',
'no-implicit-coercion': [ 'error', {
number: true,
'boolean': true,
string: true,
} ],
'no-nested-ternary': 'error',
'no-multi-str': 'error',
'no-spaced-func': 'error',
'no-with': 'error',
'object-shorthand': 'off',
'one-var': [ 'error', 'never' ],
'prefer-const': 'error',
// restricted imports and properties
'no-restricted-imports': [ 'error', RESTRICTED_MODULES ],
'no-restricted-properties': [ 2, {
object: 'process',
property: 'env',
// FIXME: restrict the rule only NEXT_PUBLIC variables
message: 'Please use configs/app/index.ts to import any NEXT_PUBLIC environment variables. For other properties please disable this rule for a while.',
} ],
},
},
{
files: [
'pages/**',
'nextjs/**',
'playwright/**',
'deploy/scripts/**',
'deploy/tools/**',
'proxy.ts',
'instrumentation*.ts',
'*.config.ts',
'*.config.js',
],
rules: {
// for configs allow to consume env variables from process.env directly
'no-restricted-properties': 'off',
},
},
{
files: [
'toolkit/chakra/**',
'toolkit/components/**',
'toolkit/package/**',
],
rules: {
// for toolkit components allow to import @chakra-ui/react directly
'no-restricted-imports': 'off',
},
},
);