refactor: migrate eslint config to TS with modular rules and add local/padding-react-hooks rule

Replace eslint.config.js with eslint.config.ts and split rules into modular
eslint/ files (react, sonar, style, tests, stories, imports). Add custom
local/padding-react-hooks rule that enforces padding around React hook calls
(no blank lines between hooks, required before/after const/expression). Add
@types/eslint-plugin-jsx-a11y and @typescript-eslint/utils as dev deps.
This commit is contained in:
melvinchia3636
2026-06-10 18:26:13 +08:00
parent 91207e7d48
commit 28584f4259
13 changed files with 379 additions and 164 deletions

View File

@@ -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 }
]
}
}
]

56
eslint.config.ts Normal file
View File

@@ -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

34
eslint/imports.ts Normal file
View File

@@ -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

6
eslint/index.ts Normal file
View File

@@ -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'

9
eslint/local-plugin.ts Normal file
View File

@@ -0,0 +1,9 @@
import paddingReactHooks from './padding-react-hooks'
const plugin = {
rules: {
'padding-react-hooks': paddingReactHooks
}
}
export default plugin

View File

@@ -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;

38
eslint/react.ts Normal file
View File

@@ -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

13
eslint/sonar.ts Normal file
View File

@@ -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

18
eslint/stories.ts Normal file
View File

@@ -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

41
eslint/style.ts Normal file
View File

@@ -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

12
eslint/tests.ts Normal file
View File

@@ -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

View File

@@ -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",

View File

@@ -7,7 +7,9 @@
"apps/api/**/*",
"docs/**/*",
"scripts/**/*",
"tools/**/*"
"tools/**/*",
"eslint.config.ts",
"eslint/**/*.ts"
],
"exclude": ["**/node_modules", "**/dist", "**/dist-docker"]
}