Ctrl K
ComunidadGuías…

frontend/eslint.config.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { readdirSync } from 'node:fs'
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

// API internals must depend on concrete files so public index exports remain a feature boundary
const restrictedApiIndexImports = [
  '@/api/accounts',
  '@/api/auth',
  '@/api/budgets',
  '@/api/categories',
  '@/api/currency',
  '@/api/dashboard',
  '@/api/import-runs',
  '@/api/insights',
  '@/api/institutions',
  '@/api/merchants',
  '@/api/oidc',
  '@/api/passkeys',
  '@/api/provider-imports',
  '@/api/tags',
  '@/api/tax-advantaged-categories',
  '@/api/transaction-imports',
  '@/api/transactions',
  '@/api/two-factor',
  '@/api/user',
  '@/api/version',
]

// The API layer talks to the backend and the query cache, never to the UI layers, so nothing
// under src/api may reach into pages, components, or contexts
const restrictedApiUiImports = ['@/pages/*', '@/components/*', '@/contexts/*']

// A path leaving its own folder is written through the alias so it reads the same wherever the
// importing file sits and survives the file being moved. Same-folder './' imports stay relative
const crossFolderRelativeImport = {
  group: ['../*', '../**'],
  message: 'Import through the @/ alias rather than a relative path that leaves the folder',
}

// A page never imports another page, and shared code never imports a page, so what two pages use lives
// in shared code. One block per page folder, since a flat config block names the files it covers and a
// pattern can only exempt the page's own folder by its name
const pageFolders = readdirSync(new URL('./src/pages', import.meta.url), { withFileTypes: true })
  .filter((entry) => entry.isDirectory())
  .map((entry) => entry.name)

const PAGE_IMPORT_MESSAGE =
  'A page never imports another page. Move what both use into shared code, such as @/utils or @/components'
const SHARED_PAGE_IMPORT_MESSAGE =
  'Shared code never imports a page. Move what the page owns into shared code, or pass it in'

// Flat config replaces a rule outright rather than merging it, so a block that sets
// no-restricted-imports has to restate every pattern that should still apply to its files
const restrictedImportPatterns = [crossFolderRelativeImport]

const STACKING_LEVEL_MESSAGE =
  'Name the stacking level: a class from the scale such as z-popover, or STACKING_LEVELS from @/constants/stackingLevels where a style object or a DOM write needs the number. Where the value only orders siblings inside one container, name a module-level constant beside it instead'

// Interpolating a level into an arbitrary class is worse than the bare number it replaces. Tailwind
// only generates a class it finds as complete literal text, so the element ends up with no stacking
// level at all rather than the wrong one, and nothing reports it
const INTERPOLATED_CLASS_MESSAGE =
  'Tailwind never generates a class assembled by interpolation, so this element would carry no stacking level at all. Use a class from the scale, such as z-popover'

const SET_PROPERTY_MESSAGE =
  'Set a stacking level through element.style.zIndex rather than through setProperty, so it reads as a level and the scale covers it'

// A stacking level written as a bare number is how the app ended up with five overlays at 110 and no
// statement anywhere of which belongs above which. Only the arbitrary bracket form of the class is
// rejected, so a bare z-10 or z-30 stays available for a level ordering siblings inside one container.
//
// One form needs several selectors, because the same value reaches the tree differently depending on
// how it was written. A class in a plain string is a Literal, while one built in a template literal
// lives in TemplateElement.value.raw, which App.tsx does. A numeric zIndex is matched on raw rather
// than value, since esquery applies a regex attribute test only to strings, and it needs a variant
// for a quoted key, for a negative value, which the parser gives as a unary minus over the digits and
// never as a literal carrying the sign, and for an assignment to element.style.zIndex.
//
// Two forms still get through: a class assembled by string concatenation, and a value chosen by a
// conditional inside the property. Neither appears anywhere in the tree, and catching either costs a
// false positive on legitimate code. Handwritten stylesheet z-index declarations are checked by
// tests/constants/stackingLevels.test.ts, since this block matches TypeScript alone
const restrictedStackingLevels = [
  {
    selector: 'Literal[value=/z-\\[\\d+\\]/]',
    message: STACKING_LEVEL_MESSAGE,
  },
  {
    selector: 'TemplateElement[value.raw=/z-\\[\\d+\\]/]',
    message: STACKING_LEVEL_MESSAGE,
  },
  {
    selector: 'TemplateElement[value.raw=/z-\\[$/]',
    message: INTERPOLATED_CLASS_MESSAGE,
  },
  {
    selector: ":matches(Property[key.name='zIndex'], Property[key.value='zIndex']) > Literal[raw=/^\\d/]",
    message: STACKING_LEVEL_MESSAGE,
  },
  {
    selector:
      ":matches(Property[key.name='zIndex'], Property[key.value='zIndex']) > UnaryExpression > Literal[raw=/^\\d/]",
    message: STACKING_LEVEL_MESSAGE,
  },
  {
    selector:
      ":matches(AssignmentExpression[left.property.name='zIndex'], AssignmentExpression[left.property.value='zIndex']) > Literal[raw=/^\\d/]",
    message: STACKING_LEVEL_MESSAGE,
  },
  {
    selector:
      ":matches(AssignmentExpression[left.property.name='zIndex'], AssignmentExpression[left.property.value='zIndex']) > UnaryExpression > Literal[raw=/^\\d/]",
    message: STACKING_LEVEL_MESSAGE,
  },
  {
    selector: "CallExpression[callee.property.name='setProperty'][arguments.0.value='z-index']",
    message: SET_PROPERTY_MESSAGE,
  },
]

// A busy page can hand an IntersectionObserver several changes in one call, oldest first, so a callback
// that reads the first entry acts on a state that has already passed and may never be told again
const LATEST_ENTRY_MESSAGE = 'Read the last entry an IntersectionObserver hands over, which is the current one'
const restrictedObserverEntries = [
  {
    selector: "NewExpression[callee.name='IntersectionObserver'] > :function > ArrayPattern",
    message: LATEST_ENTRY_MESSAGE,
  },
  {
    selector:
      "NewExpression[callee.name='IntersectionObserver'] :function MemberExpression[computed=true][property.value=0]",
    message: LATEST_ENTRY_MESSAGE,
  },
]

// A load the app abandons, such as a page left before its data arrives or a search typed over, only
// stops its request when the query function hands the request its cancel signal
const restrictedQueryFns = [
  {
    selector: "Property[key.name='queryFn']:not(:has(CallExpression Identifier[name='signal']))",
    message:
      'Take the signal from the query context and pass it to the request, so an abandoned load stops its request',
  },
]

// crypto.randomUUID is missing on a page opened over plain HTTP, such as a self-hosted app at a LAN address
const RANDOM_UUID_MESSAGE =
  'crypto.randomUUID is missing on pages opened over plain HTTP. Use createRandomId from @/utils/randomId'
// The ban follows the name, not the object, so an alias (const c = crypto), a longer path (window.self.crypto)
// and a prototype call (Crypto.prototype.randomUUID.call) all fail too
const restrictedRandomUuid = [
  { selector: "Identifier[name='randomUUID']", message: RANDOM_UUID_MESSAGE },
  { selector: "Literal[value='randomUUID']", message: RANDOM_UUID_MESSAGE },
  { selector: "TemplateElement[value.cooked='randomUUID']", message: RANDOM_UUID_MESSAGE },
]

// A native title is the browser's tooltip, which waits, looks different on every system and never shows on
// a touch or a keyboard focus. A button says what it does through HintButton or useControlHint. The ban
// covers a plain button, a motion button and any component named for a button, since a title handed to
// one ends on the button
const BUTTON_TITLE_MESSAGE =
  'A button explains itself with the app tooltip, not a native title. Use HintButton or useControlHint from @/components/tooltips, and keep aria-label for the name'
const restrictedButtonTitles = [
  {
    selector: "JSXOpeningElement[name.name=/^(button|\\w*Button)$/] > JSXAttribute[name.name='title']",
    message: BUTTON_TITLE_MESSAGE,
  },
  {
    selector:
      "JSXOpeningElement[name.type='JSXMemberExpression'][name.property.name='button'] > JSXAttribute[name.name='title']",
    message: BUTTON_TITLE_MESSAGE,
  },
]

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      reactHooks.configs.flat.recommended,
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    rules: {
      'no-restricted-imports': ['error', { patterns: restrictedImportPatterns }],
      'no-restricted-syntax': ['error', ...restrictedStackingLevels, ...restrictedObserverEntries],
    },
  },
  {
    files: ['src/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-syntax': [
        'error',
        ...restrictedStackingLevels,
        ...restrictedObserverEntries,
        ...restrictedQueryFns,
        ...restrictedRandomUuid,
        ...restrictedButtonTitles,
      ],
    },
  },
  ...pageFolders.map((page) => ({
    files: [`src/pages/${page}/**/*.{ts,tsx}`],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            ...restrictedImportPatterns,
            { group: ['@/pages/*', `!@/pages/${page}`], message: PAGE_IMPORT_MESSAGE },
          ],
        },
      ],
    },
  })),
  {
    files: ['src/{components,hooks,utils,contexts,constants}/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-imports': [
        'error',
        { patterns: [...restrictedImportPatterns, { group: ['@/pages/*'], message: SHARED_PAGE_IMPORT_MESSAGE }] },
      ],
    },
  },
  {
    files: ['src/api/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: restrictedApiIndexImports.map((name) => ({
            name,
            message: 'Import the specific API source file instead of the domain folder index',
          })),
          patterns: [
            ...restrictedImportPatterns,
            {
              group: restrictedApiUiImports,
              message: 'The API layer must not import from the UI layers',
            },
          ],
        },
      ],
    },
  },
])