refactor: enforce frontend view model boundaries
This commit is contained in:
+163
-43
@@ -20,10 +20,24 @@ 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/**",
|
||||
"**/dist/**",
|
||||
"dist-ssr/**",
|
||||
"storybook-static/**",
|
||||
"coverage/**",
|
||||
".vitest/**",
|
||||
".scannerwork/**",
|
||||
"src/shared/api/generated/**",
|
||||
"src/shared/api/spec/**",
|
||||
]),
|
||||
{
|
||||
extends: [
|
||||
@@ -35,32 +49,91 @@ export default defineConfig(
|
||||
security.configs.recommended,
|
||||
noUnsanitized.configs.recommended,
|
||||
perfectionist.configs["recommended-natural"],
|
||||
stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }),
|
||||
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",
|
||||
}],
|
||||
"@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"],
|
||||
"perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }],
|
||||
"@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 } }],
|
||||
"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 } }],
|
||||
"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" })],
|
||||
"import-x/resolver-next": [
|
||||
createTypeScriptImportResolver({ project: "tsconfig.json" }),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,18 +141,37 @@ export default defineConfig(
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
{
|
||||
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
||||
extends: [
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
importX.flatConfigs.typescript,
|
||||
],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
|
||||
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/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"],
|
||||
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",
|
||||
@@ -95,10 +187,16 @@ export default defineConfig(
|
||||
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,
|
||||
}],
|
||||
"@typescript-eslint/no-magic-numbers": [
|
||||
"error",
|
||||
{
|
||||
ignore: [-1, 0, 1, "-1n", "0n", "1n"],
|
||||
ignoreArrayIndexes: true,
|
||||
ignoreDefaultValues: true,
|
||||
ignoreNumericLiteralTypes: true,
|
||||
ignoreTypeIndexes: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -110,51 +208,73 @@ export default defineConfig(
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]],
|
||||
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" },
|
||||
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/**",
|
||||
],
|
||||
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: "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:
|
||||
"Pass a named handler from the ViewModel; prepare prop data before JSX.",
|
||||
selector:
|
||||
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
|
||||
},
|
||||
{
|
||||
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: "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 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 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 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 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)_/]",
|
||||
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)]",
|
||||
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)]",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user