diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 54a451036..000000000 --- a/eslint.config.js +++ /dev/null @@ -1,163 +0,0 @@ -import js from '@eslint/js' -import importPlugin from 'eslint-plugin-import' -import jsxA11y from 'eslint-plugin-jsx-a11y' -import perfectionist from 'eslint-plugin-perfectionist' -import pluginReact from 'eslint-plugin-react' -import reactCompiler from 'eslint-plugin-react-compiler' -import sonarjs from 'eslint-plugin-sonarjs' -import unusedImports from 'eslint-plugin-unused-imports' -import globals from 'globals' -import process from 'node:process' -import tseslint from 'typescript-eslint' - -export default [ - { - ignores: [ - '**/*.config.js', - '**/dist/', - 'dist/', - 'tools/src/templates/**', - '**/storybook-static/' - ] - }, - { - files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - languageOptions: { - globals: { - ...globals.node, - ...globals.browser - }, - sourceType: 'module', - parserOptions: { - project: './tsconfig.eslint.json', - tsconfigRootDir: process.cwd(), - sourceType: 'module' - } - } - }, - - // JS/TS Presets - js.configs.recommended, - ...tseslint.configs.recommended, - - // React & JSX Presets - pluginReact.configs.flat.recommended, - { - plugins: { - 'react-compiler': reactCompiler, - react: pluginReact - }, - rules: { - 'react-compiler/react-compiler': 'error', - 'react/react-in-jsx-scope': 'off', - 'react/jsx-sort-props': [ - 'error', - { - callbacksLast: true, - shorthandFirst: true, - shorthandLast: false, - ignoreCase: true, - noSortAlphabetically: false, - reservedFirst: true - } - ] - } - }, - - // JSX Accessibility Presets - { - files: ['**/*.{js,jsx,ts,tsx,mjs,cjs}'], - ...jsxA11y.flatConfigs.recommended, - rules: { - 'jsx-a11y/no-autofocus': 'off' - } - }, - - // SonarJS - { - ...sonarjs.configs.recommended, - rules: { - 'sonarjs/prefer-read-only-props': 'off' - } - }, - - // Import Plugin - { - plugins: { - import: importPlugin - }, - rules: { - 'import/no-duplicates': 'error' - } - }, - - // Unused Imports - { - plugins: { - 'unused-imports': unusedImports - }, - rules: { - 'no-unused-vars': 'off', - 'unused-imports/no-unused-imports': 'error', - 'unused-imports/no-unused-vars': [ - 'warn', - { - vars: 'all', - varsIgnorePattern: '^_', - args: 'after-used', - argsIgnorePattern: '^_' - } - ] - } - }, - - // Code Style and Formatting - { - rules: { - 'padding-line-between-statements': [ - 'error', - { blankLine: 'always', prev: 'import', next: '*' }, - { blankLine: 'any', prev: 'import', next: 'import' }, - { blankLine: 'always', prev: '*', next: 'const' }, - { blankLine: 'always', prev: 'const', next: '*' }, - { blankLine: 'always', prev: '*', next: 'function' }, - { blankLine: 'always', prev: '*', next: 'class' }, - { blankLine: 'always', prev: '*', next: 'export' }, - { blankLine: 'always', prev: 'export', next: '*' }, - { blankLine: 'always', prev: '*', next: 'block-like' }, - { blankLine: 'always', prev: '*', next: 'return' } - ], - '@typescript-eslint/no-unused-vars': [ - 'error', - { - vars: 'all', - args: 'after-used', - ignoreRestSiblings: true, - varsIgnorePattern: '^_', - argsIgnorePattern: '^_' - } - ] - } - }, - - // Test Files - Allow explicit any - { - files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'], - rules: { - '@typescript-eslint/no-explicit-any': 'off' - } - }, - - // Stories Files - Sort object properties alphabetically (auto-fixable) - { - files: ['**/*.stories.tsx', '**/*.stories.ts'], - plugins: { perfectionist }, - rules: { - 'sort-keys': 'off', - 'perfectionist/sort-objects': [ - 'error', - { type: 'natural', order: 'asc', ignoreCase: true } - ] - } - } -] diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 000000000..0dad3bf6e --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,56 @@ +import js from '@eslint/js' +import type { Linter } from 'eslint' +import globals from 'globals' +import process from 'node:process' +import tseslint from 'typescript-eslint' + +import { + importsConfig, + reactConfig, + sonarConfig, + storiesConfig, + styleConfig, + testsConfig +} from './eslint/index' + +const config: Linter.Config[] = [ + { + ignores: [ + '**/*.config.ts', + '**/*.config.js', + '**/dist/', + 'dist/', + 'tools/src/templates/**', + '**/storybook-static/' + ] + }, + { + files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + languageOptions: { + globals: { + ...globals.node, + ...globals.browser + }, + sourceType: 'module', + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: process.cwd(), + sourceType: 'module' + } + } + }, + + // JS/TS Presets + js.configs.recommended as Linter.Config, + ...(tseslint.configs.recommended as Linter.Config[]), + + // Feature / Plugin Configs + ...reactConfig, + ...sonarConfig, + ...importsConfig, + ...styleConfig, + ...testsConfig, + ...storiesConfig +] + +export default config diff --git a/eslint/imports.ts b/eslint/imports.ts new file mode 100644 index 000000000..fb75edd1c --- /dev/null +++ b/eslint/imports.ts @@ -0,0 +1,34 @@ +import type { Linter } from 'eslint' +import importPlugin from 'eslint-plugin-import' +import unusedImports from 'eslint-plugin-unused-imports' + +const config: Linter.Config[] = [ + { + plugins: { + import: importPlugin + }, + rules: { + 'import/no-duplicates': 'error' + } + }, + { + plugins: { + 'unused-imports': unusedImports + }, + rules: { + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_' + } + ] + } + } +] + +export default config diff --git a/eslint/index.ts b/eslint/index.ts new file mode 100644 index 000000000..152377a13 --- /dev/null +++ b/eslint/index.ts @@ -0,0 +1,6 @@ +export { default as importsConfig } from './imports' +export { default as reactConfig } from './react' +export { default as sonarConfig } from './sonar' +export { default as storiesConfig } from './stories' +export { default as styleConfig } from './style' +export { default as testsConfig } from './tests' diff --git a/eslint/local-plugin.ts b/eslint/local-plugin.ts new file mode 100644 index 000000000..490c1662b --- /dev/null +++ b/eslint/local-plugin.ts @@ -0,0 +1,9 @@ +import paddingReactHooks from './padding-react-hooks' + +const plugin = { + rules: { + 'padding-react-hooks': paddingReactHooks + } +} + +export default plugin diff --git a/eslint/padding-react-hooks.ts b/eslint/padding-react-hooks.ts new file mode 100644 index 000000000..3e8e654f2 --- /dev/null +++ b/eslint/padding-react-hooks.ts @@ -0,0 +1,147 @@ +import type { Rule } from 'eslint' +import type { TSESTree } from '@typescript-eslint/utils' + +const rule: Rule.RuleModule = { + meta: { + type: 'layout', + docs: { + description: 'Enforce padding lines between and around React hook calls.', + category: 'Stylistic Issues' + }, + fixable: 'whitespace', + schema: [], + messages: { + noBlankLineBetweenHooks: 'Expected no blank line between hook calls.', + blankLineRequiredAfterHook: 'Expected blank line after hook calls.', + blankLineRequiredBeforeHook: 'Expected blank line before hook calls.', + blankLineRequiredBetweenConsts: 'Expected blank line between const declarations.' + } + }, + create(context) { + const sourceCode = context.sourceCode; + + function isHookCall(node: any): boolean { + if (!node || node.type !== 'CallExpression') return false; + const callee = node.callee; + if (callee.type === 'Identifier') { + return /^use[A-Z]/.test(callee.name); + } + if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') { + return /^use[A-Z]/.test(callee.property.name); + } + return false; + } + + function isHookStatement(statement: TSESTree.Statement): boolean { + if (statement.type === 'ExpressionStatement') { + return isHookCall((statement as TSESTree.ExpressionStatement).expression); + } + if (statement.type === 'VariableDeclaration') { + return (statement as TSESTree.VariableDeclaration).declarations.some(decl => isHookCall(decl.init)); + } + return false; + } + + function isConstDeclaration(statement: TSESTree.Statement): boolean { + return statement.type === 'VariableDeclaration' && (statement as TSESTree.VariableDeclaration).kind === 'const'; + } + + function hasBlankLineBetween(startLine: number, endLine: number): boolean { + for (let line = startLine + 1; line < endLine; line++) { + const lineText = sourceCode.lines[line - 1]; + if (lineText.trim() === '') { + return true; + } + } + return false; + } + + function checkStatements(statements: TSESTree.Statement[]) { + for (let i = 0; i < statements.length - 1; i++) { + const current = statements[i]; + const next = statements[i + 1]; + + const currentIsHook = isHookStatement(current); + const nextIsHook = isHookStatement(next); + + const currentIsConst = isConstDeclaration(current); + const nextIsConst = isConstDeclaration(next); + + const currentIsExpr = current.type === 'ExpressionStatement'; + const nextIsExpr = next.type === 'ExpressionStatement'; + + const currentEndLine = current.loc?.end.line; + const nextStartLine = next.loc?.start.line; + if (currentEndLine === undefined || nextStartLine === undefined) continue; + + if (currentIsHook && nextIsHook) { + if (hasBlankLineBetween(currentEndLine, nextStartLine)) { + context.report({ + node: next as any, + messageId: 'noBlankLineBetweenHooks', + fix(fixer) { + const rangeStart = current.range?.[1]; + const rangeEnd = next.range?.[0]; + if (rangeStart === undefined || rangeEnd === undefined) return null; + const textBetween = sourceCode.text.slice(rangeStart, rangeEnd); + const fixedText = textBetween.replace(/\r?\n\s*\r?\n/g, '\n'); + return fixer.replaceTextRange([rangeStart, rangeEnd], fixedText); + } + }); + } + } else if (currentIsHook && (nextIsConst || nextIsExpr)) { + if (!hasBlankLineBetween(currentEndLine, nextStartLine)) { + context.report({ + node: next as any, + messageId: 'blankLineRequiredAfterHook', + fix(fixer) { + return fixer.insertTextBefore(next as any, '\n'); + } + }); + } + } else if ((currentIsConst || currentIsExpr) && nextIsHook) { + if (!hasBlankLineBetween(currentEndLine, nextStartLine)) { + context.report({ + node: next as any, + messageId: 'blankLineRequiredBeforeHook', + fix(fixer) { + return fixer.insertTextBefore(next as any, '\n'); + } + }); + } + } else if (currentIsConst && (nextIsConst || nextIsExpr)) { + if (!hasBlankLineBetween(currentEndLine, nextStartLine)) { + context.report({ + node: next as any, + messageId: 'blankLineRequiredBetweenConsts', + fix(fixer) { + return fixer.insertTextBefore(next as any, '\n'); + } + }); + } + } else if (currentIsExpr && nextIsConst) { + if (!hasBlankLineBetween(currentEndLine, nextStartLine)) { + context.report({ + node: next as any, + messageId: 'blankLineRequiredBetweenConsts', + fix(fixer) { + return fixer.insertTextBefore(next as any, '\n'); + } + }); + } + } + } + } + + return { + BlockStatement(node: any) { + checkStatements(node.body); + }, + Program(node: any) { + checkStatements(node.body); + } + }; + } +} + +export default rule; diff --git a/eslint/react.ts b/eslint/react.ts new file mode 100644 index 000000000..e82e83b17 --- /dev/null +++ b/eslint/react.ts @@ -0,0 +1,38 @@ +import type { Linter } from 'eslint' +import jsxA11y from 'eslint-plugin-jsx-a11y' +import pluginReact from 'eslint-plugin-react' +import reactCompiler from 'eslint-plugin-react-compiler' + +const config: Linter.Config[] = [ + pluginReact.configs.flat.recommended as Linter.Config, + { + plugins: { + 'react-compiler': reactCompiler, + react: pluginReact + }, + rules: { + 'react-compiler/react-compiler': 'error', + 'react/react-in-jsx-scope': 'off', + 'react/jsx-sort-props': [ + 'error', + { + callbacksLast: true, + shorthandFirst: true, + shorthandLast: false, + ignoreCase: true, + noSortAlphabetically: false, + reservedFirst: true + } + ] + } + }, + { + files: ['**/*.{js,jsx,ts,tsx,mjs,cjs}'], + ...jsxA11y.flatConfigs.recommended, + rules: { + 'jsx-a11y/no-autofocus': 'off' + } + } as Linter.Config +] + +export default config diff --git a/eslint/sonar.ts b/eslint/sonar.ts new file mode 100644 index 000000000..78e0ef3ea --- /dev/null +++ b/eslint/sonar.ts @@ -0,0 +1,13 @@ +import type { Linter } from 'eslint' +import sonarjs from 'eslint-plugin-sonarjs' + +const config: Linter.Config[] = [ + { + ...sonarjs.configs?.recommended, + rules: { + 'sonarjs/prefer-read-only-props': 'off' + } + } as Linter.Config +] + +export default config diff --git a/eslint/stories.ts b/eslint/stories.ts new file mode 100644 index 000000000..a999e2392 --- /dev/null +++ b/eslint/stories.ts @@ -0,0 +1,18 @@ +import type { Linter } from 'eslint' +import perfectionist from 'eslint-plugin-perfectionist' + +const config: Linter.Config[] = [ + { + files: ['**/*.stories.tsx', '**/*.stories.ts'], + plugins: { perfectionist }, + rules: { + 'sort-keys': 'off', + 'perfectionist/sort-objects': [ + 'error', + { type: 'natural', order: 'asc', ignoreCase: true } + ] + } + } +] + +export default config diff --git a/eslint/style.ts b/eslint/style.ts new file mode 100644 index 000000000..c7259b8fe --- /dev/null +++ b/eslint/style.ts @@ -0,0 +1,41 @@ +import type { Linter } from 'eslint' +import localPlugin from './local-plugin' + +const config: Linter.Config[] = [ + { + plugins: { + local: localPlugin + }, + rules: { + 'local/padding-react-hooks': 'error', + 'padding-line-between-statements': [ + 'error', + { blankLine: 'always', prev: 'import', next: '*' }, + { blankLine: 'any', prev: 'import', next: 'import' }, + { blankLine: 'always', prev: '*', next: 'const' }, + { blankLine: 'always', prev: 'const', next: '*' }, + { blankLine: 'always', prev: '*', next: 'function' }, + { blankLine: 'always', prev: '*', next: 'class' }, + { blankLine: 'always', prev: '*', next: 'export' }, + { blankLine: 'always', prev: 'export', next: '*' }, + { blankLine: 'always', prev: '*', next: 'block-like' }, + { blankLine: 'always', prev: '*', next: 'return' }, + { blankLine: 'any', prev: 'const', next: 'const' }, + { blankLine: 'any', prev: 'const', next: 'expression' }, + { blankLine: 'any', prev: 'expression', next: 'const' } + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + argsIgnorePattern: '^_' + } + ] + } + } +] + +export default config diff --git a/eslint/tests.ts b/eslint/tests.ts new file mode 100644 index 000000000..7379601d9 --- /dev/null +++ b/eslint/tests.ts @@ -0,0 +1,12 @@ +import type { Linter } from 'eslint' + +const config: Linter.Config[] = [ + { + files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'], + rules: { + '@typescript-eslint/no-explicit-any': 'off' + } + } +] + +export default config diff --git a/package.json b/package.json index 91785cfe4..1412c51ce 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@lifeforge/configs": "workspace:*", "@originjs/vite-plugin-federation": "^1.4.1", "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/lodash": "^4.17.21", "@types/opentype.js": "^1.3.10", "@types/prettier": "^3.0.0", @@ -46,6 +47,7 @@ "@types/tinycolor2": "^1.4.6", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", + "@typescript-eslint/utils": "8.61.0", "@typescript/native-preview": "^7.0.0-dev.20260609.1", "@vitejs/plugin-react": "^4.4.1", "bun-types": "latest", diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 531dd274e..5eeffc7e9 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -7,7 +7,9 @@ "apps/api/**/*", "docs/**/*", "scripts/**/*", - "tools/**/*" + "tools/**/*", + "eslint.config.ts", + "eslint/**/*.ts" ], "exclude": ["**/node_modules", "**/dist", "**/dist-docker"] }