mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-06-28 06:46:24 +00:00
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:
163
eslint.config.js
163
eslint.config.js
@@ -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
56
eslint.config.ts
Normal 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
34
eslint/imports.ts
Normal 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
6
eslint/index.ts
Normal 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
9
eslint/local-plugin.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import paddingReactHooks from './padding-react-hooks'
|
||||
|
||||
const plugin = {
|
||||
rules: {
|
||||
'padding-react-hooks': paddingReactHooks
|
||||
}
|
||||
}
|
||||
|
||||
export default plugin
|
||||
147
eslint/padding-react-hooks.ts
Normal file
147
eslint/padding-react-hooks.ts
Normal 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
38
eslint/react.ts
Normal 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
13
eslint/sonar.ts
Normal 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
18
eslint/stories.ts
Normal 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
41
eslint/style.ts
Normal 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
12
eslint/tests.ts
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"apps/api/**/*",
|
||||
"docs/**/*",
|
||||
"scripts/**/*",
|
||||
"tools/**/*"
|
||||
"tools/**/*",
|
||||
"eslint.config.ts",
|
||||
"eslint/**/*.ts"
|
||||
],
|
||||
"exclude": ["**/node_modules", "**/dist", "**/dist-docker"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user