2 de septiembre de 2026

ESLint 10: qué se rompe al actualizar (y por qué ESLint 9 ya es inseguro)

Foto de Marco Orta Marco Orta | 12 min de lectura
Compartir
Dos archivos de configuración de vidrio índigo apilados: el de abajo se desmorona en fragmentos mientras el de arriba permanece intacto y brillando
Tabla de Contenidos

    ESLint 9 llegó a su fin de vida el 6 de agosto de 2026. Si tu CI todavía corre eslint@9, lleva cuatro semanas funcionando sin parches de seguridad, y nadie te lo va a avisar. ESLint 10 salió en general availability el 6 de febrero de 2026, lo que significa que la ventana de seis meses de traslape que el proyecto siempre da ya se agotó. El cambio central no es una regla nueva ni un parser más rápido: es que el sistema de configuración eslintrc, el que se arma alrededor de .eslintrc.json y .eslintrc.js, se eliminó del código. No se deprecó. No quedó detrás de un flag. Se eliminó. Hay exactamente una forma de configurar ESLint 10: eslint.config.js.

    Esto pesa más que la mayoría de saltos de versión mayor porque ESLint viene anunciando esta eliminación exacta desde que flat config se volvió el default en la v9, allá por abril de 2024. Los equipos tuvieron dos años de aviso. Muchos usaron ese tiempo para no hacer nada, porque la config vieja seguía funcionando. Ahora deja de funcionar.

    ¿De dónde vienes?

    El costo de la migración se divide fuerte según tres puntos de partida.

    Desde ESLint 9 con flat config ya instalado. Estás en la mejor posición. Tu eslint.config.js ya existe y ya funciona como ESLint 10 lo espera. Lo que sí te puede tocar: el piso de versión de Node.js se movió, el set de reglas por default ganó tres reglas que no habías visto, y —si trabajas en un monorepo— el algoritmo de búsqueda del archivo de configuración cambió de una forma que puede alterar en silencio qué reglas aplican a qué archivos.

    Desde ESLint 9 apoyado todavía en el shim FlatCompat de @eslint/eslintrc. Este fue el camino intermedio pragmático que tomaron muchos equipos en 2024-2025: escribir un eslint.config.js, pero usar FlatCompat.extends() adentro para seguir jalando una config compartida de estilo viejo (airbnb-base, un paquete interno eslint-config-company) que nunca sacó release de flat config. La buena noticia, y no es obvia, es que este camino sigue funcionando en ESLint 10 —FlatCompat no murió con eslintrc, es un paquete aparte y sigue mantenido. Sí necesitas asegurarte de que @eslint/eslintrc esté en una versión actual, porque las copias viejas son anteriores a los internos de ESLint 10.

    Desde ESLint 8, o desde ESLint 9 sin ningún eslint.config.js (es decir, dependías del fallback automático de ESLint 9 al formato legado cuando no encontraba flat config). Este es el grupo que se rompe más duro, porque ese fallback es justo lo que se eliminó. Apunta ESLint 10 a un proyecto que solo tiene .eslintrc.json y ningún eslint.config.js, y no va a usar el archivo viejo calladito —va a fallar en encontrar cualquier configuración.

    Las rupturas, ordenadas por lo que te van a costar

    1. eslintrc desapareció. No hay flag para traerlo de vuelta.

    Esta es la que todo mundo medio espera y aun así lo agarra, porque la eliminación real es más amplia que “el formato de archivo viejo deja de leerse”. Cuatro cosas se van juntas:

    • .eslintrc.js, .eslintrc.json, .eslintrc.yml y .eslintignore ya no se leen, punto.
    • La variable de entorno ESLINT_USE_FLAT_CONFIG, que antes te dejaba forzar a ESLint 9 de vuelta al sistema legado, ya no se respeta. Configurarla no hace nada —no truena, simplemente se ignora.
    • Los flags del CLI que solo tenían sentido para eslintrc desaparecieron: --no-eslintrc, --env, --resolve-plugins-relative-to, --rulesdir, --ignore-path. Los scripts que los pasan ahora fallan al arrancar.
    • La opción del constructor configType de Linter solo acepta "flat". Pásale "eslintrc" y truena.

    La migración en sí, de .eslintrc.json a eslint.config.js, es mecánica una vez que la haces. Aquí va una real, antes y después:

    Antes — .eslintrc.json:

    {
      "root": true,
      "env": {
        "browser": true,
        "es2021": true,
        "node": true
      },
      "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
      "parser": "@typescript-eslint/parser",
      "parserOptions": {
        "ecmaVersion": "latest",
        "sourceType": "module"
      },
      "plugins": ["@typescript-eslint"],
      "rules": {
        "no-unused-vars": "off",
        "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }]
      },
      "ignorePatterns": ["dist", "node_modules"]
    }
    

    Después — eslint.config.js:

    import js from '@eslint/js';
    import globals from 'globals';
    import tseslint from 'typescript-eslint';
    
    export default tseslint.config(
      {
        ignores: ['dist/**', 'node_modules/**'],
      },
      js.configs.recommended,
      ...tseslint.configs.recommended,
      {
        languageOptions: {
          globals: {
            ...globals.browser,
            ...globals.node,
          },
        },
        rules: {
          '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
        },
      },
    );
    

    Tres cosas cambiaron de forma, no solo de sintaxis: env se volvió languageOptions.globals jalado del paquete globals, extends se volvió un arreglo que esparces directo (tseslint.configs.recommended ya es un arreglo de objetos de configuración), y no-unused-vars: off desapareció —@typescript-eslint/no-unused-vars ya no necesita que silencies a mano la regla base porque tseslint.configs.recommended ya la delimita bien por archivo.

    Si tu único bloqueo restante es una config compartida sin release de flat config, para eso es FlatCompat:

    import { FlatCompat } from '@eslint/eslintrc';
    import path from 'node:path';
    import { fileURLToPath } from 'node:url';
    
    const compat = new FlatCompat({
      baseDirectory: path.dirname(fileURLToPath(import.meta.url)),
    });
    
    export default [
      ...compat.extends('airbnb-base'),
      // el resto de tu flat config
    ];
    

    Eso sigue siendo un eslint.config.js de verdad —FlatCompat nada más deja que una config compartida de formato viejo viva adentro. Es un puente, no el regreso de eslintrc.

    2. El algoritmo de búsqueda de config cambió —silencioso en monorepos

    Esta es silenciosa por diseño, y es el cambio con más probabilidad de morder a quien hizo todo “bien”. ESLint 10 dejó de resolver eslint.config.* desde el directorio de trabajo actual. En vez de eso, arranca la búsqueda desde el directorio de cada archivo que se está analizando y sube desde ahí. El objetivo declarado es que un monorepo pueda tener un eslint.config.js distinto por paquete sin cableado extra —lo cual es genuinamente útil. El costo es que un archivo ahora puede recoger en silencio una config distinta a la que tenía en ESLint 9, sin aviso ni error: el lint simplemente pasa o falla distinto a antes, y la razón es un archivo de configuración dos directorios más allá que se te olvidó que existía. Si corres un monorepo, analiza cada workspace por separado justo después de actualizar y compara la salida contra ESLint 9 antes de confiar en ella.

    3. Tres reglas se acaban de activar en eslint:recommended —también en silencio

    Si tu config incluye js.configs.recommended (antes eslint:recommended), actualizar agrega no-unassigned-vars, no-useless-assignment y preserve-caught-error a lo que estás exigiendo —sin que toques una sola línea de tu propia config. Esta es la ruptura silenciosa clásica: corres el mismo comando de siempre, y ahora falla o advierte sobre código que no cambió. Si tu CI trata los warnings como fallas, eso es un build en rojo la mañana después del bump, con un diff que no muestra nada que hayas hecho mal.

    no-useless-assignment atrapa el valor que se sobreescribe antes de leerse:

    function total(items) {
      let sum = 0; // esta asignación...
      sum = items.reduce((a, b) => a + b, 0); // ...se reemplaza de inmediato, nunca se lee
      return sum;
    }
    

    preserve-caught-error marca un error capturado que descartas en vez de encadenar:

    // antes — el error original y su stack se pierden
    try {
      parseConfig(raw);
    } catch (err) {
      throw new Error('Config parsing failed');
    }
    
    // después — la causa original sobrevive para quien depure esto después
    try {
      parseConfig(raw);
    } catch (err) {
      throw new Error('Config parsing failed', { cause: err });
    }
    

    Nada de esto está mal de exigir —es buen consejo. El problema es enterarte por un deploy fallido en vez de por un changelog.

    4. Los comentarios /* eslint-env */ pasan de inertes en silencio a error explícito

    Esta tiene una historia de dos pasos que vale la pena conocer. Flat config nunca soportó comentarios tipo /* eslint-env browser */ —llevan sin hacer nada en silencio desde que te moviste a flat config en ESLint 9, te hayas dado cuenta o no. ESLint 10 cierra ese silencio: el mismo comentario ahora se reporta como error de lint en vez de ignorarse calladito. Si nunca migraste esos comentarios a languageOptions.globals, vas a ver errores nuevecitos apuntando a código que “funcionaba” desde hace año y medio, porque los globals que necesitaba nunca se estaban declarando de verdad —el comentario simplemente no hacía nada.

    /* eslint-env browser, node */
    window.dispatchEvent(new Event('ready'));
    

    Mueve la intención a la config, igual que el ejemplo de .eslintrc de arriba hizo con env:

    {
      languageOptions: {
        globals: { ...globals.browser, ...globals.node },
      },
    }
    

    5. El piso de Node.js se movió, y no es solo un bump de número de versión

    ESLint 10 requiere Node.js ^20.19.0 || ^22.13.0 || >=24. Node 21.x y 23.x —los lanzamientos impares que no son LTS— quedan sin soporte de plano, y cualquier cosa debajo de 20.19.0 o 22.13.0 no tiene soporte aunque el mayor sea el correcto. En package.json:

    // antes
    "engines": { "node": ">=18" }
    
    // después
    "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }
    

    Si tu imagen de CI fija un patch viejo dentro de 20.x o 22.x, npm install puede seguir teniendo éxito mientras el linter mismo falla al arrancar con un error que no parece tener nada que ver con Node —instalas ESLint y ya está roto antes de leer un solo archivo.

    6. Métodos eliminados de context y SourceCode rompen reglas propias y plugins viejos

    Si escribes reglas propias, o dependes de un plugin interno que nadie toca desde 2023, aquí es donde muerde: context.getCwd(), context.getFilename(), context.getPhysicalFilename(), context.getSourceCode(), context.parserOptions y context.parserPath desaparecieron todos, junto con SourceCode#getTokenOrCommentBefore(), getTokenOrCommentAfter(), isSpaceBetweenTokens() y getJSDocComment(). La clase Linter en sí perdió defineParser(), defineRule(), defineRules() y getRules(). Existen reemplazos para los que importan (context.cwd, context.filename, context.sourceCode, isSpaceBetween()), pero una regla que solo llama a la API eliminada en una ruta de código rara no truena hasta que esa ruta corre —lo que puede significar que pasa el CI durante semanas y luego falla en un archivo específico.

    7. eslint.config.ts necesita un jiti actual

    ESLint 10 deja de soportar versiones de jiti anteriores a 2.2.0, lo cual importa solo si escribes tu config en TypeScript (eslint.config.ts) y dejas que jiti la transpile al vuelo. Debajo de 2.2.0, la falla no dice “actualiza jiti” —sale como un error de resolución de módulos sin relación aparente, que manda a la mayoría por el camino de depuración equivocado primero.

    Lo que no se rompe

    Vale la pena decirlo claro, porque el marco de “todo se rompe” tampoco es exacto. typescript-eslint ya soporta ESLint 10 (su rango de peer declarado es ^8.57.0 || ^9.0.0 || ^10.0.0), así que un proyecto en una versión moderna de typescript-eslint no necesita tocarlo para esta actualización. Y FlatCompat —la válvula de escape real, ya cubierta arriba— sigue viva y con releases.

    El único lugar donde este stack te obliga la mano es Astro. El release actual de eslint-plugin-astro exige ESLint 10 de plano, así que un proyecto de Astro que actualiza el plugin de Astro por cualquier otra razón arrastra a ESLint con él, haya sido planeado el bump de ESLint o no. Revisa los dos juntos antes de tocar cualquiera.

    ¿Cuándo actualizar?

    Ahora mismo, si sigues en ESLint 9 o antes. El fin de vida ya pasó hace un mes al momento de escribir esto; ya no queda versión de “espera a que se asiente el polvo” de ese argumento.

    Esta semana, si estás en ESLint 9 con flat config ya migrado. Tu superficie de riesgo es el cambio de búsqueda en monorepo y las tres reglas nuevas por default —las dos están a una corrida de lint de hacerse visibles, no a una reescritura.

    Después de una auditoría, si mantienes reglas propias o un plugin interno. Haz grep de tus implementaciones de reglas buscando getCwd, getFilename, getSourceCode y parserOptions antes de subir la versión, no después de que el CI te avise.

    En una rama dedicada primero, si corres un monorepo. Analiza cada workspace de forma aislada, compara los resultados contra ESLint 9, y solo entonces haz merge —el cambio de búsqueda de config es exactamente el tipo de cosa que se ve bien en local y se porta mal en el paquete que no probaste tú mismo.

    Con FlatCompat como puente, si dependes de una config compartida que nunca sacó soporte flat. Eso no es razón para quedarte en un mayor sin soporte —es la herramienta que te deja actualizar hoy y terminar la migración real después.

    Para seguir:

    Preguntas frecuentes

    Compartir

    Buscar

    Etiquetas

    PHP Laravel IA Tutorial JavaScript Migración Desarrollo Web Buenas Prácticas Upgrade Seguridad Laravel 13 Backend SEO Claude Herramientas