Files
frontend/eslint.config.mjs
T
ksv741 ca0ad4f824
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s
refactor: enforce frontend view model boundaries
2026-09-28 18:48:23 +03:00

283 lines
9.1 KiB
JavaScript

import react from "@eslint-react/eslint-plugin";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import noUnsanitized from "eslint-plugin-no-unsanitized";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn";
import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals";
import tseslint from "typescript-eslint";
import frontend from "./eslint-rules.mjs";
export default defineConfig(
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
plugins: { frontend },
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
},
globalIgnores([
"**/dist/**",
"dist-ssr/**",
"storybook-static/**",
"coverage/**",
".vitest/**",
".scannerwork/**",
"src/shared/api/generated/**",
"src/shared/api/spec/**",
]),
{
extends: [
js.configs.recommended,
importX.flatConfigs.recommended,
unicorn.configs.recommended,
sonarjs.configs.recommended,
regexp.configs.recommended,
security.configs.recommended,
noUnsanitized.configs.recommended,
perfectionist.configs["recommended-natural"],
stylistic.configs.customize({
indent: "tab",
jsx: true,
quotes: "double",
semi: true,
}),
],
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
linterOptions: { reportUnusedDisableDirectives: "error" },
rules: {
// A comma disambiguates generic arrow functions from JSX in TSX.
"@stylistic/comma-dangle": [
"error",
{
arrays: "always-multiline",
enums: "always-multiline",
exports: "always-multiline",
functions: "always-multiline",
generics: "ignore",
imports: "always-multiline",
objects: "always-multiline",
tuples: "always-multiline",
},
],
// Keep text/interpolation together: splitting can change rendered whitespace.
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
"@stylistic/max-len": [
"error",
{
code: 80,
ignoreComments: true,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreTrailingComments: true,
ignoreUrls: true,
tabWidth: 2,
},
],
"@stylistic/multiline-ternary": ["error", "always-multiline"],
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
"@stylistic/operator-linebreak": ["error", "before"],
"@stylistic/padding-line-between-statements": [
"error",
{ blankLine: "always", next: "return", prev: "*" },
{
blankLine: "always",
next: "*",
prev: ["multiline-const", "multiline-let", "block-like", "type"],
},
{
blankLine: "always",
next: ["multiline-const", "multiline-let", "block-like", "type"],
prev: "*",
},
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
],
"no-nested-ternary": "error",
"perfectionist/sort-classes": [
"error",
{
groups: ["static-method", "property", "method", "unknown"],
type: "natural",
},
],
// Preserve the repository's component, hook and config file naming conventions.
"unicorn/filename-case": [
"error",
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
],
// React uses Props/Ref names, including the recommended React ref-name rule.
"unicorn/name-replacements": [
"error",
{ replacements: { props: false, ref: false } },
],
// React, Mantine and Fetch accept null at their documented call boundaries.
"unicorn/no-null": ["error", { checkArguments: false }],
"unicorn/prefer-ternary": ["error", "only-single-line"],
},
settings: {
"import-x/resolver-next": [
createTypeScriptImportResolver({ project: "tsconfig.json" }),
],
},
},
{
files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"],
languageOptions: { globals: globals.node },
},
{
extends: [
tseslint.configs.recommendedTypeChecked,
importX.flatConfigs.typescript,
],
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/switch-exhaustiveness-check": "error",
},
},
{
extends: [
react.configs["recommended-type-checked"],
hooks.configs.flat.recommended,
query.configs["flat/recommended"],
],
files: [
"src/**/*.{ts,tsx}",
"packages/*/src/**/*.{ts,tsx}",
".storybook/preview.tsx",
],
languageOptions: { globals: globals.browser },
rules: {
"import-x/exports-last": "error",
"import-x/no-default-export": "error",
},
},
{
extends: [refresh.configs.vite],
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
},
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1, "-1n", "0n", "1n"],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true,
ignoreTypeIndexes: true,
},
],
},
},
{
extends: [vitest.configs.recommended],
files: ["**/*.test.{ts,tsx}"],
rules: {
"vitest/no-focused-tests": "error",
"vitest/valid-expect": ["error", { maxArgs: 2 }],
},
},
{
extends: [
testingLibrary.configs["flat/react"],
jestDom.configs["flat/recommended"],
],
files: ["**/*.test.tsx"],
},
storybook.configs["flat/recommended"],
{
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
rules: {
"import-x/exports-last": "off",
"import-x/no-default-export": "off",
},
},
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-unsafe-enum-comparison": "error",
"no-restricted-syntax": [
"error",
{
message:
"Pass a named handler from the ViewModel; prepare prop data before JSX.",
selector:
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
},
{
message: "Name the derived prop value before JSX.",
selector:
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
},
{
message:
"Use the owning enum member for machine codes and protocol values.",
selector:
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
},
{
message:
"Use the owning enum member instead of a template machine code.",
selector:
"TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
},
{
message:
"Use an enum member instead of an inline code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
},
{
message:
"Use an enum member instead of constructing a code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
},
{
message: "Use a computed enum member as the protocol mapping key.",
selector:
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
},
{
message:
"Compare codes with their enum members, not string literals.",
selector:
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
},
],
},
},
);