import { FlatCompat } from '@eslint/eslintrc' import prettier from 'eslint-config-prettier/flat' import checkFile from 'eslint-plugin-check-file' import { globalIgnores } from 'eslint/config' import { dirname } from 'path' import { fileURLToPath } from 'url' import { legacyCamelCaseFilenameIgnores, legacyComponentFilenameIgnores, legacyComponentSyntaxIgnores, legacyFrameworkExportIgnores, } from './eslint-legacy-ignores.mjs' import { localRules } from './eslint-local-rules.mjs' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) // eslint-config-next ships legacy (eslintrc) configs, so wrap them with // FlatCompat to use them from this flat config with ESLint 9. const compat = new FlatCompat({ baseDirectory: __dirname }) const frameworkComponentFiles = [ 'src/app/**/{page,layout,loading,error,not-found,template,default,global-error}.tsx', ] // `as never` silences every type check on the expression (worse than `any`). // Fix the types instead; a justified eslint-disable comment is the only // sanctioned escape hatch. const noAsNeverCast = { selector: 'TSAsExpression > TSNeverKeyword', message: 'Do not cast with `as never` — it disables type checking entirely. Fix the types or use a narrowly-typed helper.', } // Consent documents are resolved by stable key, never by hardcoded id (ids // differ per environment and the lookup must fail closed). History: // `consent: 2` shipped in submitWhatsapp and needed a data migration // (20260725_170000) to untangle. Source-only: tests legitimately forge // numeric consent ids to prove schemas strip mass-assigned fields. const noHardcodedConsentId = { selector: "Property[key.name='consent'][value.type='Literal'][value.raw=/^[0-9]+$/]", message: 'Never hardcode a Consent document id — resolve it by stable key via requireConsentByKey (keys in src/lib/campaignConsentKeys.ts).', } // E2E fixtures must derive dates from "today": a hardcoded ISO instant // silently expires once it stops matching a time-windowed default view. The // B196 activities fixture (`startAt: '2026-09-01...'`) emptied the default // `proximos` tab and blocked the 2026-09-11 deploy verify. Derive with // `civilDatePlusDays(formatBahiaCivilDate(new Date()), days)` + // `parseBahiaDateTimeInput` (tests/e2e/helpers/agendaPeriodLabels.ts, // src/lib/campaignTime.ts). Deliberately narrow (start*/end*/date* keys): // state timestamps like `disabledAt`/`lastSyncedAt` are the scenario under // test, not a time window. const noHardcodedE2eFixtureDate = { selector: "Property[key.name=/^(start|end|date)/][value.type='Literal'][value.raw=/20[0-9]{2}-[0-9]{2}-[0-9]{2}T/]", message: 'Never hardcode a fixture date in e2e specs — derive it from today (civilDatePlusDays + parseBahiaDateTimeInput); a literal expires and empties time-windowed lists (B196 deploy verify, 2026-09-11).', } const eslintConfig = [ ...compat.extends('next/core-web-vitals', 'next/typescript'), { files: ['src/**/*.tsx'], ignores: [ ...frameworkComponentFiles, ...legacyComponentFilenameIgnores, ...legacyFrameworkExportIgnores, ...legacyComponentSyntaxIgnores, ], plugins: { 'check-file': checkFile, local: localRules, }, rules: { 'check-file/filename-naming-convention': [ 'error', { '**/*.tsx': 'PASCAL_CASE', }, ], 'local/component-arrow-conventions': 'error', 'local/no-component-default-export': 'error', 'react/function-component-definition': [ 'error', { namedComponents: 'arrow-function', unnamedComponents: 'arrow-function', }, ], }, }, { files: frameworkComponentFiles, ignores: legacyFrameworkExportIgnores, plugins: { 'check-file': checkFile, local: localRules, }, rules: { 'check-file/filename-naming-convention': [ 'error', { '**/*.tsx': '@(page|layout|loading|error|not-found|template|default|global-error)', }, ], 'local/framework-default-export': 'error', 'react/function-component-definition': 'off', }, }, { files: [ 'src/components/**/*.{js,ts}', 'src/lib/**/*.{js,ts}', 'src/utilities/**/*.{js,ts}', 'tests/**/*.{js,jsx,ts,tsx}', ], ignores: legacyCamelCaseFilenameIgnores, plugins: { 'check-file': checkFile, }, rules: { 'check-file/filename-naming-convention': [ 'error', { '**/*.{js,jsx,ts,tsx}': 'CAMEL_CASE', }, { ignoreMiddleExtensions: true, }, ], }, }, { // src/lib is the pure, client-safe layer (docs/ARCHITECTURE.md): it must // never depend on Payload/Next server code, on utilities/, or on higher // layers. Types shared with utilities live in lib contract modules and // utilities re-export FROM lib — a lib module type-importing utilities // inverts the direction anyway (Pass 4: suggestionCatalog did exactly // that for a type whose source already lived in lib). files: ['src/lib/**/*.{ts,tsx}'], rules: { '@typescript-eslint/no-restricted-imports': [ 'error', { patterns: [ { group: ['@/utilities/**', '@/components/**', '@/app/**'], message: 'src/lib is the pure layer — it must not depend on utilities/components/app. Move the pure logic into lib/ or this module into utilities/. Shared types live in lib contract modules.', allowTypeImports: false, }, { group: ['payload', 'payload/**', '@payload-config', 'next', 'next/**'], message: 'src/lib must stay Payload/Next-free. Server-coupled code lives in src/utilities (marked server-only).', allowTypeImports: true, }, ], }, ], }, }, { // The 623 KB committed TSE artifact must never enter a client bundle. // These three loaders carry it transitively; server components and other // utilities import them freely, client components must not (P3-K — B13 // avoided it by discipline, not mechanism). Types stay safe. files: ['src/components/**/*.{ts,tsx}'], rules: { '@typescript-eslint/no-restricted-imports': [ 'error', { patterns: [ { group: [ '@/utilities/municipality/municipalityTerritorialClass', '@/utilities/municipality/municipalityPotential', '@/utilities/territory/territoryIntraCaptureBenchmark', '@/lib/bahiaElectionAggregates', '@/lib/electionAggregates/**', ], message: 'The committed TSE artifact (~623 KB) must not reach the browser bundle — resolve the values in the RSC and pass them down as props.', allowTypeImports: true, }, ], }, ], }, }, { // Underscore-prefixed bindings are deliberate placeholders (unused action // state, ignored tuple slots); everything else unused is dead code. files: ['**/*.{js,mjs,jsx,ts,tsx}'], rules: { '@typescript-eslint/no-unused-vars': [ 'warn', { args: 'all', argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', varsIgnorePattern: '^_', }, ], 'no-restricted-syntax': ['error', noAsNeverCast], }, }, { // Catch Next.js invalid-use-server-value before `next build` (Vercel). // MUST come after the global no-restricted-syntax block: for the same // rule, the last matching config object wins wholesale, so this src-only // widening (as-never + consent-id ban) would otherwise be overridden. files: ['src/**/*.{ts,tsx}'], plugins: { local: localRules, }, rules: { 'local/use-server-async-exports': 'error', 'no-restricted-syntax': ['error', noAsNeverCast, noHardcodedConsentId], }, }, { // Payload generates migration signatures with unused destructured args. files: ['src/migrations/**/*.ts'], rules: { '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], }, }, { // E2E fixture dates must derive from "today" (B196 deploy verify, // 2026-09-11): a hardcoded ISO instant expires and empties the // time-windowed default view. Keeps the global as-never ban too. files: ['tests/e2e/**/*.{ts,tsx}'], rules: { 'no-restricted-syntax': ['error', noAsNeverCast, noHardcodedE2eFixtureDate], }, }, prettier, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: '.next/**', '.next-e2e/**', 'out/**', 'build/**', 'next-env.d.ts', // Playwright artifacts (generated on e2e failures/reports): 'playwright-report/**', 'test-results/**', // Vitest coverage output (generated under the --coverage flag): 'coverage/**', // Vendored agent material (same carve-out as .prettierignore): '.agents/**', ]), ] export default eslintConfig