Ctrl K
ComunidadGuías…

frontend-modern/eslint.config.js

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

Archivo original ↗Preguntar a KiBot
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import solid from 'eslint-plugin-solid';
import prettier from 'eslint-config-prettier';
import globals from 'globals';

export default tseslint.config(
  { ignores: ['dist', 'public', 'node_modules', 'src/api/generated'] },
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      globals: { ...globals.browser, ...globals.node },
      parserOptions: {
        sourceType: 'module',
      },
    },
    plugins: {
      solid,
    },
    rules: {
      ...solid.configs.typescript.rules,
      '@typescript-eslint/no-unused-vars': [
        'warn',
        {
          argsIgnorePattern: '^_',
          varsIgnorePattern: '^_',
          caughtErrorsIgnorePattern: '^_',
        },
      ],
      '@typescript-eslint/no-unused-expressions': 'off',
      '@typescript-eslint/no-explicit-any': 'off',
      'no-case-declarations': 'off',
      'no-useless-escape': 'off',
      'prefer-const': 'off',
      'solid/reactivity': 'off',
      'solid/prefer-for': 'off',
      'solid/style-prop': 'off',
      'solid/components-return-once': 'off',
      'solid/self-closing-comp': 'off',
    },
  },
  // Prevent metric threshold/color duplication in components.
  // Centralized definitions live in src/utils/metricThresholds.ts.
  {
    files: ['src/components/**/*.{ts,tsx}', 'src/pages/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-syntax': [
        'error',
        // Metric color/threshold functions — use @/utils/metricThresholds
        {
          selector:
            'FunctionDeclaration[id.name=/^get(Bar|Usage|Memory|Disk|Cpu|Metric|Threshold)(Color|Colour)/i]',
          message:
            'Use getMetricColor*/getMetricSeverity from @/utils/metricThresholds instead of defining local metric color functions.',
        },
        {
          selector:
            'VariableDeclarator[id.name=/^get(Bar|Usage|Memory|Disk|Cpu|Metric|Threshold)(Color|Colour)/i]',
          message:
            'Use getMetricColor*/getMetricSeverity from @/utils/metricThresholds instead of defining local metric color functions.',
        },
        {
          selector: "VariableDeclarator[id.name='METRIC_THRESHOLDS']",
          message:
            'Import METRIC_THRESHOLDS from @/utils/metricThresholds instead of redefining it.',
        },
        // Time formatting — use formatRelativeTime from @/utils/format
        {
          selector: 'FunctionDeclaration[id.name=/^format(RelativeTime|TimeAgo)$/i]',
          message:
            'Use formatRelativeTime from @/utils/format instead of defining local time formatting functions.',
        },
        {
          selector: 'VariableDeclarator[id.name=/^format(RelativeTime|TimeAgo)$/i]',
          message:
            'Use formatRelativeTime from @/utils/format instead of defining local time formatting functions.',
        },
        // formatPowerOnHours — use @/utils/format
        {
          selector: "FunctionDeclaration[id.name='formatPowerOnHours']",
          message: 'Use formatPowerOnHours from @/utils/format instead of defining it locally.',
        },
        {
          selector: "VariableDeclarator[id.name='formatPowerOnHours']",
          message: 'Use formatPowerOnHours from @/utils/format instead of defining it locally.',
        },
        // estimateTextWidth — use @/utils/format
        {
          selector: "FunctionDeclaration[id.name='estimateTextWidth']",
          message: 'Use estimateTextWidth from @/utils/format instead of defining it locally.',
        },
        {
          selector: "VariableDeclarator[id.name='estimateTextWidth']",
          message: 'Use estimateTextWidth from @/utils/format instead of defining it locally.',
        },
        // formatAnomalyRatio / ANOMALY_SEVERITY_CLASS — use @/utils/format
        {
          selector: "FunctionDeclaration[id.name='formatAnomalyRatio']",
          message: 'Use formatAnomalyRatio from @/utils/format instead of defining it locally.',
        },
        {
          selector: "VariableDeclarator[id.name='formatAnomalyRatio']",
          message: 'Use formatAnomalyRatio from @/utils/format instead of defining it locally.',
        },
        {
          selector: "VariableDeclarator[id.name='ANOMALY_SEVERITY_CLASS']",
          message: 'Import ANOMALY_SEVERITY_CLASS from @/utils/format instead of redefining it.',
        },
        // Canvas DPR setup — use setupCanvasDPR from @/utils/canvasRenderQueue
        {
          selector: 'FunctionDeclaration[id.name=/^setupCanvas(For)?DPR$/i]',
          message:
            'Use setupCanvasDPR from @/utils/canvasRenderQueue instead of defining it locally.',
        },
        {
          selector: 'VariableDeclarator[id.name=/^setupCanvas(For)?DPR$/i]',
          message:
            'Use setupCanvasDPR from @/utils/canvasRenderQueue instead of defining it locally.',
        },
        // Tooltip reimplementation — use useTooltip() from @/hooks/useTooltip
        {
          selector:
            "CallExpression[callee.name='createSignal'] > ArrayExpression > Identifier[name=/^(showTooltip|tooltipVisible|tooltipPos|tooltipPosition)$/]",
          message:
            'Use the useTooltip() hook from @/hooks/useTooltip instead of reimplementing tooltip state.',
        },
        // Block hardcoded legacy colors in favor of semantic design system.
        // Only flags shades (50-200, 700-800) that look wrong in the opposite
        // color mode. Mid-range grays (300-600) are mode-neutral. 900/950 are
        // reserved for intentional terminal/code UIs.
        // dark:/hover:/focus: variants are excluded by the (?:^|\s) anchor.
        {
          selector:
            'Literal[value=/(?:^|\\s)(?:bg|text|border|ring)-(?:slate|gray|zinc|neutral)-(?:50|100|200|700|800)(?:$|\\s)/]',
          message:
            'Use semantic design system classes (e.g. bg-surface, text-base-content, border-border) instead of hardcoded tailwind grays. See DESIGN_SYSTEM.md for the token reference.',
        },
        {
          selector:
            'TemplateElement[value.raw=/(?:^|\\s)(?:bg|text|border|ring)-(?:slate|gray|zinc|neutral)-(?:50|100|200|700|800)(?:$|\\s)/]',
          message:
            'Use semantic design system classes (e.g. bg-surface, text-base-content, border-border) instead of hardcoded tailwind grays. See DESIGN_SYSTEM.md for the token reference.',
        },
        // Full-viewport shells must use semantic background tokens.
        // Hardcoded Tailwind color scales on min-h-screen wrappers are a recurring
        // source of light/dark mismatches on auth/loading screens.
        {
          selector:
            'Literal[value=/(?=.*(?:^|\\s)min-h-screen(?:$|\\s))(?=.*(?:^|\\s)bg-(?:white|black|slate-\\d{2,3}|gray-\\d{2,3}|zinc-\\d{2,3}|neutral-\\d{2,3}|stone-\\d{2,3}|red-\\d{2,3}|orange-\\d{2,3}|amber-\\d{2,3}|yellow-\\d{2,3}|lime-\\d{2,3}|green-\\d{2,3}|emerald-\\d{2,3}|teal-\\d{2,3}|cyan-\\d{2,3}|sky-\\d{2,3}|blue-\\d{2,3}|indigo-\\d{2,3}|violet-\\d{2,3}|purple-\\d{2,3}|fuchsia-\\d{2,3}|pink-\\d{2,3}|rose-\\d{2,3})(?:$|\\s))/]',
          message:
            'Full-screen wrappers (min-h-screen) must use semantic backgrounds (bg-base/bg-surface/bg-surface-alt) instead of hardcoded palette classes.',
        },
        {
          selector:
            'TemplateElement[value.raw=/(?=.*(?:^|\\s)min-h-screen(?:$|\\s))(?=.*(?:^|\\s)bg-(?:white|black|slate-\\d{2,3}|gray-\\d{2,3}|zinc-\\d{2,3}|neutral-\\d{2,3}|stone-\\d{2,3}|red-\\d{2,3}|orange-\\d{2,3}|amber-\\d{2,3}|yellow-\\d{2,3}|lime-\\d{2,3}|green-\\d{2,3}|emerald-\\d{2,3}|teal-\\d{2,3}|cyan-\\d{2,3}|sky-\\d{2,3}|blue-\\d{2,3}|indigo-\\d{2,3}|violet-\\d{2,3}|purple-\\d{2,3}|fuchsia-\\d{2,3}|pink-\\d{2,3}|rose-\\d{2,3})(?:$|\\s))/]',
          message:
            'Full-screen wrappers (min-h-screen) must use semantic backgrounds (bg-base/bg-surface/bg-surface-alt) instead of hardcoded palette classes.',
        },
        // Catch orphaned CSS prefixes (e.g. "hover: " with no utility after it).
        // These produce no CSS output and are always a bug from bad find-replace.
        {
          selector:
            'Literal[value=/(?:^|\\s)(?:hover|focus|active|dark|group-hover|peer-focus|before|after|lg|md|sm|xl|2xl):\\s/]',
          message:
            "Orphaned CSS prefix detected (e.g. 'hover: ' with nothing after it). This is a broken class string — the utility class is missing.",
        },
        {
          selector:
            'TemplateElement[value.raw=/(?:^|\\s)(?:hover|focus|active|dark|group-hover|peer-focus|before|after|lg|md|sm|xl|2xl):\\s/]',
          message:
            "Orphaned CSS prefix detected (e.g. 'hover: ' with nothing after it). This is a broken class string — the utility class is missing.",
        },
        // Block dark-mode white backgrounds, which are almost always unreadable.
        {
          selector: 'Literal[value=/(?:^|\\s)dark:(?:hover:)?bg-white(?:$|\\s)/]',
          message:
            'Avoid dark:bg-white / dark:hover:bg-white. Use semantic surface tokens for dark mode-safe contrast.',
        },
        {
          selector: 'TemplateElement[value.raw=/(?:^|\\s)dark:(?:hover:)?bg-white(?:$|\\s)/]',
          message:
            'Avoid dark:bg-white / dark:hover:bg-white. Use semantic surface tokens for dark mode-safe contrast.',
        },
        // Prevent low-contrast combinations that caused prior regressions.
        {
          selector:
            'Literal[value=/(?=.*(?:^|\\s)bg-base(?:$|\\s))(?=.*(?:^|\\s)text-white(?:$|\\s))/]',
          message:
            'Avoid combining bg-base with text-white. Use semantic pairs like bg-surface + text-base-content or a dedicated accent background.',
        },
        {
          selector:
            'TemplateElement[value.raw=/(?=.*(?:^|\\s)bg-base(?:$|\\s))(?=.*(?:^|\\s)text-white(?:$|\\s))/]',
          message:
            'Avoid combining bg-base with text-white. Use semantic pairs like bg-surface + text-base-content or a dedicated accent background.',
        },
      ],
    },
  },
  {
    files: [
      'src/components/Settings/DiagnosticsPanel.tsx',
      'src/components/Settings/ReportingPanel.tsx',
      'src/components/Settings/SystemLogsPanel.tsx',
    ],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: [
            {
              name: '@/components/shared/SettingsPanel',
              message:
                'Use @/components/Settings/OperationsPanel so Operations tab headers remain consistent.',
            },
          ],
        },
      ],
    },
  },
  // Components and features reach the API through @/utils/apiClient so org
  // scoping, auth and retry stay in one place.
  {
    files: ['src/components/**/*.{ts,tsx}', 'src/features/**/*.{ts,tsx}'],
    ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}'],
    rules: {
      'no-restricted-globals': [
        'error',
        {
          name: 'fetch',
          message:
            'Use apiFetch or apiFetchJSON from @/utils/apiClient so org scoping, auth and retry stay in one place.',
        },
      ],
    },
  },
  // Presentational Settings sections receive data and actions from their
  // owning state hook instead of calling the API themselves.
  {
    files: [
      'src/components/Settings/DiagnosticsResultsPanel.tsx',
      'src/components/Settings/AgentIntegrationsPanel.tsx',
      'src/components/Settings/AIModelSelectionSection.tsx',
      'src/components/Settings/UpdateHistorySection.tsx',
    ],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: [
            {
              name: '@/utils/apiClient',
              message:
                'Presentational Settings sections receive data and actions from their owning state hook; do not call the API client here.',
            },
          ],
        },
      ],
    },
  },
  // API modules read statuses through the responseUtils helpers.
  {
    files: ['src/api/*.ts'],
    ignores: ['src/api/responseUtils.ts', 'src/api/__tests__/**', 'src/api/generated/**'],
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector:
            "CallExpression[callee.type='MemberExpression'][callee.property.name='includes'][arguments.0.value=/^40[24]$/]",
          message:
            "Do not infer 402/404 from error text. Use isAPIErrorStatus(error, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "BinaryExpression[operator=/^[!=]==$/][left.type='MemberExpression'][left.object.name='response'][left.property.name='status'][right.raw=/^(204|404|503)$/]",
          message:
            "Do not branch on response.status 204/404/503. Use isAPIResponseStatus(response, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "BinaryExpression[operator=/^[!=]==$/][right.type='MemberExpression'][right.object.name='response'][right.property.name='status'][left.raw=/^(204|404|503)$/]",
          message:
            "Do not branch on response.status 204/404/503. Use isAPIResponseStatus(response, status) from '@/api/responseUtils'.",
        },
      ],
    },
  },
  // Governed parsing modules parse through responseUtils.
  {
    files: ['src/api/aiChat.ts', 'src/api/monitoring.ts'],
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector:
            "CallExpression[callee.type='MemberExpression'][callee.property.name='includes'][arguments.0.value=/^40[24]$/]",
          message:
            "Do not infer 402/404 from error text. Use isAPIErrorStatus(error, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "BinaryExpression[operator=/^[!=]==$/][left.type='MemberExpression'][left.object.name='response'][left.property.name='status'][right.raw=/^(204|404|503)$/]",
          message:
            "Do not branch on response.status 204/404/503. Use isAPIResponseStatus(response, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "BinaryExpression[operator=/^[!=]==$/][right.type='MemberExpression'][right.object.name='response'][right.property.name='status'][left.raw=/^(204|404|503)$/]",
          message:
            "Do not branch on response.status 204/404/503. Use isAPIResponseStatus(response, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "CallExpression[callee.type='MemberExpression'][callee.object.name='response'][callee.property.name='json']",
          message:
            "Parse through parseRequiredAPIResponse / parseOptionalAPIResponse / parseRequiredJSON from '@/api/responseUtils', not response.json().",
        },
        {
          selector:
            "CallExpression[callee.type='MemberExpression'][callee.object.name='JSON'][callee.property.name='parse']",
          message:
            'Parse through the shared responseUtils parsers (or consumeJSONEventStream for SSE), not JSON.parse().',
        },
      ],
    },
  },
  // Governed collection modules do not invent empty collections.
  {
    files: [
      'src/api/alerts.ts',
      'src/api/nodes.ts',
      'src/api/notifications.ts',
      'src/api/patrol.ts',
      'src/api/security.ts',
    ],
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector:
            "CallExpression[callee.type='MemberExpression'][callee.property.name='includes'][arguments.0.value=/^40[24]$/]",
          message:
            "Do not infer 402/404 from error text. Use isAPIErrorStatus(error, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "BinaryExpression[operator=/^[!=]==$/][left.type='MemberExpression'][left.object.name='response'][left.property.name='status'][right.raw=/^(204|404|503)$/]",
          message:
            "Do not branch on response.status 204/404/503. Use isAPIResponseStatus(response, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "BinaryExpression[operator=/^[!=]==$/][right.type='MemberExpression'][right.object.name='response'][right.property.name='status'][left.raw=/^(204|404|503)$/]",
          message:
            "Do not branch on response.status 204/404/503. Use isAPIResponseStatus(response, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "LogicalExpression[operator=/^(\\|\\||\\?\\?)$/][right.type='ArrayExpression'][right.elements.length=0]",
          message:
            "Do not fall back to [] in a governed API module; it invents absence. Use the shared collection normalizers in '@/api/responseUtils' or fail on an unreadable payload.",
        },
        {
          selector:
            "ConditionalExpression[test.type='CallExpression'][test.callee.object.name='Array'][test.callee.property.name='isArray'][alternate.type='ArrayExpression'][alternate.elements.length=0]",
          message:
            "Do not normalize with Array.isArray(x) ? x : []. Use the shared collection normalizers in '@/api/responseUtils'.",
        },
      ],
    },
  },
  // Modules governed for both parsing and collections.
  {
    files: ['src/api/ai.ts', 'src/api/agentProfiles.ts'],
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector:
            "CallExpression[callee.type='MemberExpression'][callee.property.name='includes'][arguments.0.value=/^40[24]$/]",
          message:
            "Do not infer 402/404 from error text. Use isAPIErrorStatus(error, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "BinaryExpression[operator=/^[!=]==$/][left.type='MemberExpression'][left.object.name='response'][left.property.name='status'][right.raw=/^(204|404|503)$/]",
          message:
            "Do not branch on response.status 204/404/503. Use isAPIResponseStatus(response, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "BinaryExpression[operator=/^[!=]==$/][right.type='MemberExpression'][right.object.name='response'][right.property.name='status'][left.raw=/^(204|404|503)$/]",
          message:
            "Do not branch on response.status 204/404/503. Use isAPIResponseStatus(response, status) from '@/api/responseUtils'.",
        },
        {
          selector:
            "CallExpression[callee.type='MemberExpression'][callee.object.name='response'][callee.property.name='json']",
          message:
            "Parse through parseRequiredAPIResponse / parseOptionalAPIResponse / parseRequiredJSON from '@/api/responseUtils', not response.json().",
        },
        {
          selector:
            "CallExpression[callee.type='MemberExpression'][callee.object.name='JSON'][callee.property.name='parse']",
          message:
            'Parse through the shared responseUtils parsers (or consumeJSONEventStream for SSE), not JSON.parse().',
        },
        {
          selector:
            "LogicalExpression[operator=/^(\\|\\||\\?\\?)$/][right.type='ArrayExpression'][right.elements.length=0]",
          message:
            "Do not fall back to [] in a governed API module; it invents absence. Use the shared collection normalizers in '@/api/responseUtils' or fail on an unreadable payload.",
        },
        {
          selector:
            "ConditionalExpression[test.type='CallExpression'][test.callee.object.name='Array'][test.callee.property.name='isArray'][alternate.type='ArrayExpression'][alternate.elements.length=0]",
          message:
            "Do not normalize with Array.isArray(x) ? x : []. Use the shared collection normalizers in '@/api/responseUtils'.",
        },
      ],
    },
  },
  // Platform tables use PlatformTableToolbar and createPlatformTableFilterState.
  {
    files: [
      'src/features/docker/**/*.tsx',
      'src/features/kubernetes/**/*.tsx',
      'src/features/truenas/**/*.tsx',
      'src/features/vmware/**/*.tsx',
      'src/features/standalone/**/*.tsx',
      'src/features/proxmox/Proxmox*Table.tsx',
    ],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: [
            {
              name: '@/components/shared/SearchInput',
              message:
                'Platform tables take search from PlatformTableToolbar and createPlatformTableFilterState, not a standalone SearchInput.',
            },
            {
              name: '@/components/shared/FilterButtonGroup',
              message:
                'Platform tables take filters from PlatformTableToolbar and createPlatformTableFilterState, not a standalone FilterButtonGroup.',
            },
          ],
        },
      ],
    },
  },
  // No bespoke search signal in a platform inventory table.
  {
    files: [
      'src/features/docker/*Table.tsx',
      'src/features/kubernetes/*Table.tsx',
      'src/features/truenas/*Table.tsx',
      'src/features/vmware/*Table.tsx',
      'src/features/standalone/*Table.tsx',
    ],
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector:
            "VariableDeclarator[id.type='ArrayPattern'][id.elements.0.name='search'][init.callee.name='createSignal']",
          message:
            'No bespoke search signal in a platform table. Read search from createPlatformTableFilterState.',
        },
      ],
    },
  },
  // Proxmox inline detail drawers render tables through PlatformDetailTable*.
  {
    files: [
      'src/features/proxmox/ProxmoxMailGatewayDrawer.tsx',
      'src/features/proxmox/ProxmoxCephClusterDrawer.tsx',
    ],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: [
            {
              name: '@/components/shared/SearchInput',
              message:
                'Platform tables take search from PlatformTableToolbar and createPlatformTableFilterState, not a standalone SearchInput.',
            },
            {
              name: '@/components/shared/FilterButtonGroup',
              message:
                'Platform tables take filters from PlatformTableToolbar and createPlatformTableFilterState, not a standalone FilterButtonGroup.',
            },
            {
              name: '@/components/shared/Table',
              importNames: ['Table', 'TableHeader', 'TableBody'],
              message:
                'Inline detail tables use PlatformDetailTable / PlatformDetailTableHeader / PlatformDetailTableBody from sharedPlatformPage.',
            },
          ],
        },
      ],
      'no-restricted-syntax': [
        'error',
        {
          selector: 'JSXOpeningElement[name.name=/^(table|thead|tbody)$/]',
          message:
            'No raw <table>/<thead>/<tbody> in a Proxmox detail drawer. Use PlatformDetailTable* or TechnicalDetailsSection cards.',
        },
      ],
    },
  },
  // TrueNAS Protection keeps search, status and sort out of the URL
  // (storage-recovery forbidden path 9).
  {
    files: ['src/features/truenas/TrueNASProtectionTable.tsx'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: [
            {
              name: '@/components/shared/SearchInput',
              message:
                'Platform tables take search from PlatformTableToolbar and createPlatformTableFilterState, not a standalone SearchInput.',
            },
            {
              name: '@/components/shared/FilterButtonGroup',
              message:
                'Platform tables take filters from PlatformTableToolbar and createPlatformTableFilterState, not a standalone FilterButtonGroup.',
            },
            {
              name: '@solidjs/router',
              importNames: ['useSearchParams'],
              message:
                'TrueNAS Protection keeps its filter and sort state out of the URL. Use createPlatformTableFilterState.',
            },
          ],
          patterns: [
            {
              group: ['@/routing/resourceLinks'],
              message: 'TrueNAS Protection must not build recovery links or URL state.',
            },
          ],
        },
      ],
    },
  },
  prettier,
);