import { defineConfig } from 'eslint/config' import nextVitals from 'eslint-config-next/core-web-vitals' import nextTs from 'eslint-config-next/typescript' import prettierConfig from 'eslint-config-prettier' import prettierPlugin from 'eslint-plugin-prettier' import unusedImportsPlugin from 'eslint-plugin-unused-imports' import tseslint from 'typescript-eslint' /** * Strip plugin registrations from typescript-eslint shared configs so they do not * collide with eslint-config-next (which already registers @typescript-eslint). */ function rulesOnly(configs, files = ['**/*.{ts,tsx}']) { return configs .filter((config) => config.rules && Object.keys(config.rules).length > 0) .map((config) => ({ files: config.files ?? files, rules: config.rules, })) } /** * Frontend ESLint — Next.js core-web-vitals + typescript-eslint strictTypeChecked. * * Intentional overrides (still a high quality bar): * - no-unnecessary-condition: off — optional DTO/API fields produce many false positives * - no-redundant-type-constituents: off — `string | 'literal'` is intentional public API style * - prefer-nullish-coalescing: ignorePrimitives — flag object nullish only, not `x || 0` * - react-hooks/set-state-in-effect: off — HeroUI / controlled-input patterns * * Hybrid with Oxlint (`pnpm lint` = oxlint then eslint): * ESLint remains the authority for the full rule bar. Do not weaken rules here * to satisfy Oxlint. Rules left ESLint-only (not ported or incomplete in Oxlint): * no-restricted-syntax, import/order, padding-line-between-statements, react/jsx-sort-props, * prettier/prettier, and no-misused-promises (Oxlint option parity for JSX attributes). * no-restricted-imports stays on in Oxlint for app/components/features, with the same * wrapper exceptions ESLint ignores (formElements, consumer/input, heroui). */ const eslintConfig = defineConfig([ { ignores: [ '.next/**', 'out/**', 'build/**', 'next-env.d.ts', 'node_modules/**', '.now/**', '*.css', '.changeset/**', 'dist/**', 'esm/**', 'public/**', 'tests/**', 'scripts/**', '*.config.js', '*.config.mjs', '*.config.ts', '!playwright.config.ts', '.DS_Store', 'coverage/**', 'playwright-report/**', 'test-results/**', 'api/generated/**', 'orval.config.ts', 'vitest.config.ts', ], }, ...nextVitals, ...nextTs, { files: ['**/*.{ts,tsx}'], languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, }, }, }, ...rulesOnly(tseslint.configs.strictTypeChecked), ...rulesOnly(tseslint.configs.stylisticTypeChecked), { files: ['**/*.{js,jsx,ts,tsx}'], plugins: { prettier: prettierPlugin, 'unused-imports': unusedImportsPlugin, }, settings: { react: { version: 'detect', }, 'import/resolver': { typescript: { alwaysTryTypes: true, }, }, }, rules: { 'no-console': ['error', { allow: ['warn', 'error'] }], eqeqeq: ['error', 'smart'], 'react/prop-types': 'off', 'react/jsx-uses-react': 'off', 'react/react-in-jsx-scope': 'off', 'react-hooks/exhaustive-deps': 'error', 'react-hooks/set-state-in-effect': 'off', 'react-hooks/incompatible-library': 'off', 'react-hooks/preserve-manual-memoization': 'off', 'react/no-unescaped-entities': 'error', 'react/self-closing-comp': 'error', 'jsx-a11y/media-has-caption': 'off', 'jsx-a11y/no-autofocus': 'off', 'jsx-a11y/click-events-have-key-events': 'error', 'jsx-a11y/interactive-supports-focus': 'error', 'prettier/prettier': 'error', 'no-unused-vars': 'off', 'unused-imports/no-unused-vars': 'off', 'unused-imports/no-unused-imports': 'error', '@typescript-eslint/no-unused-vars': [ 'error', { args: 'after-used', ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unsafe-function-type': 'error', '@typescript-eslint/consistent-type-imports': [ 'error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, ], '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': [ 'error', { checksVoidReturn: { attributes: false } }, ], '@typescript-eslint/restrict-template-expressions': [ 'error', { allowNumber: true, allowBoolean: true, allowNullish: true }, ], '@typescript-eslint/prefer-optional-chain': 'error', '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', // Mostly flags intentional Boolean()/Number() coercions at API/UI boundaries. '@typescript-eslint/no-unnecessary-type-conversion': 'off', '@typescript-eslint/prefer-nullish-coalescing': [ 'error', { ignorePrimitives: { string: true, number: true, boolean: true, bigint: true, }, }, ], '@typescript-eslint/no-base-to-string': [ 'error', { ignoredTypeNames: ['Error', 'DOMException', 'URL', 'URLSearchParams', 'RegExp'], }, ], '@typescript-eslint/no-empty-function': [ 'error', { allow: ['arrowFunctions', 'functions', 'methods'] }, ], 'import/order': [ 'error', { groups: ['type', 'builtin', 'object', 'external', 'internal', 'parent', 'sibling', 'index'], pathGroups: [ { pattern: '@/**', group: 'internal', position: 'before', }, { pattern: '~/**', group: 'external', position: 'after', }, ], pathGroupsExcludedImportTypes: ['builtin'], 'newlines-between': 'always', }, ], 'react/jsx-sort-props': [ 'warn', { callbacksLast: true, shorthandFirst: true, noSortAlphabetically: false, reservedFirst: true, }, ], 'padding-line-between-statements': [ 'error', { blankLine: 'always', prev: '*', next: 'return' }, { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' }, { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, ], }, }, { files: ['app/**/*.{js,jsx,ts,tsx}', 'components/**/*.{js,jsx,ts,tsx}', 'features/**/*.{js,jsx,ts,tsx}'], ignores: ['components/formElements/**', 'components/consumer/input/**', 'components/heroui/**', '**/*.{test,spec}.{js,jsx,ts,tsx}'], rules: { 'no-restricted-imports': [ 'error', { paths: [ { name: '@/components/heroui/Input', message: 'Use `@/components/formElements/Input` (supports FormProvider and standalone value/onValueChange).', }, { name: '@/components/heroui/Select', message: 'Use `@/components/formElements/Input` with generalType="select".', }, { name: '@/components/heroui/NumberInput', message: 'Use `@/components/formElements/Input` with generalType="numberInput".', }, { name: '@/components/heroui/InputOtp', message: 'Use `@/components/formElements/Input` with generalType="otp".', }, { name: '@/components/heroui/Autocomplete', message: 'Use `@/components/formElements/Input` with generalType="combobox".', }, { name: '@/components/heroui/SelectionControls', message: 'Use `@/components/formElements/Input` (checkbox/radio/switch) or `@/components/formElements/Checkbox`.', }, ], }, ], 'no-restricted-syntax': [ 'error', { selector: "JSXOpeningElement[name.name='button']", message: 'Use `@/components/formElements/Button` instead of raw