La lección anterior terminó con una observación incómoda: los tres fallos que resolviste tenían una señal previa que nadie vio. Un id que mezclaba tipos, un catch que se tragaba un error en silencio, dos líneas gemelas de las que solo una se adaptó. Ninguno necesitaba ejecutarse para ser sospechoso: bastaba con leer el código con atención, y eso lo hace una máquina mejor que tú, en cien milisegundos y sin cansarse. En esta lección montarás esa máquina sobre Nómada Tareas: ESLint para detectar patrones peligrosos antes de ejecutar nada, Prettier para que el formato deje de ser un tema de conversación, un hook de Git para que nada mal formateado entre en el repositorio y un flujo de integración continua que lo compruebe otra vez en el servidor. Y terminarás fijando las convenciones que ninguna herramienta puede automatizar: nombres, tamaño de funciones, comentarios que explican el porqué y documentación de tipos con JSDoc.

Contenido

  1. Qué cuesta no tener herramientas de calidad
  2. Análisis estático: qué puede saber una máquina sin ejecutar nada
  3. ESLint: instalación y primer arranque
  4. El fichero de configuración plano
  5. Reglas, niveles y configuraciones recomendadas
  6. globals: navegador, Node y pruebas
  7. Diez reglas que evitan bugs reales
  8. Desactivar una regla sin hacer trampas
  9. --fix y los scripts del proyecto
  10. Plugins útiles
  11. Prettier: el formato deja de ser una opinión
  12. ESLint frente a Prettier, y cómo convivir
  13. Integración en el editor
  14. Hooks de Git con Husky y lint-staged
  15. Integración continua con GitHub Actions
  16. Convenciones que ninguna herramienta puede imponer
  17. Documentar tipos con JSDoc
  18. // @ts-check: comprobación de tipos sin TypeScript
  19. Métricas de calidad con cabeza
  20. Nómada Tareas: configurarlo todo y arreglar los avisos
  21. Errores Comunes y Consejos
  22. Ejercicios
  23. Conclusión

  1. Qué cuesta no tener herramientas de calidad

En un proyecto sin automatizar, la calidad se sostiene sobre la revisión humana. Y la revisión humana tiene un problema de presupuesto de atención: quien revisa dispone de una cantidad limitada de energía, y si la gasta en cosas que una máquina resolvería sola, no le queda para lo que solo un humano puede ver.

Un comentario típico de revisión sin herramientas:

«Aquí hay comillas dobles y en el resto del fichero simples. Falta el punto y coma de la línea 34. La indentación del if está a 4 y usamos 2. Y creo que estado no se usa.»

Cuatro comentarios; los cuatro los detecta y arregla una herramienta en un segundo. Lo que no ha dicho nadie, porque la atención se fue en el formato: que ese if no contempla el caso hecha, o que la función devuelve undefined en una rama.

El coste real se reparte en cuatro sumandos:

Coste Qué es Qué lo elimina
Discutir estilo Comillas, sangría, punto y coma, longitud de línea Un formateador determinista
Ruido en los diffs Cambios de formato mezclados con cambios de lógica El mismo formateador, aplicado a todos
Bugs de patrón Variables sin usar, == inesperado, promesas sin await Un analizador estático
Deriva entre personas Cada fichero escrito con un estilo distinto Configuración compartida en el repositorio

Los dos primeros son de comodidad. El tercero es de corrección, y es el que justifica la lección: hay una familia entera de fallos que se detectan leyendo el código, sin ejecutarlo.

  1. Análisis estático: qué puede saber una máquina sin ejecutar nada

Un analizador estático convierte tu código en un árbol de sintaxis abstracta (AST) —la misma estructura que construye el motor de JavaScript antes de ejecutar— y recorre ese árbol buscando patrones. No ejecuta nada; razona sobre la forma.

flowchart LR
    A["Código fuente<br/>tablero.js"] --> B["Analizador<br/>→ AST"]
    B --> C["Reglas<br/>recorren el árbol"]
    C --> D["Avisos y errores<br/>fichero:línea:columna"]
    C --> E["Correcciones<br/>automáticas (--fix)"]

Lo que un analizador puede saber:

  • Que declaraste const visibles y nunca la usaste.
  • Que llamas a tablro.resumen() y ese identificador no existe en ningún ámbito accesible.
  • Que una función async no contiene ningún await (probablemente sobra el async… o falta un await).
  • Que un case de un switch no tiene break y cae al siguiente (02-03).
  • Que comparas con == en lugar de === (01-07).
  • Que hay una asignación dentro de un if: if (estado = 'hecha').

Lo que no puede saber:

  • Si horasAbiertas debe sumar sobre las abiertas o sobre todas. Eso es semántica, y es exactamente el caso 3 de la lección anterior. Para eso hacen falta pruebas.
  • Si la aplicación hace lo que Marta necesita.
  • Si tu API devolverá id como cadena o como número, salvo que se lo digas con tipos.

Es importante tener clara esa frontera: el análisis estático y las pruebas automatizadas no compiten, se complementan. El primero es barato, instantáneo y cubre una familia estrecha de fallos; las segundas cuestan escribirse y cubren el comportamiento. Un proyecto serio tiene los dos.

  1. ESLint: instalación y primer arranque

ESLint es el analizador estático estándar del ecosistema JavaScript. Es configurable hasta el último detalle y extensible con plugins, y esas dos características explican tanto su potencia como su fama de "configuración complicada".

Nómada Tareas hasta ahora no tenía package.json: es un proyecto de módulos ES servidos directamente. Lo creamos ahora, porque a partir de aquí el proyecto tiene herramientas:

cd nomada-tareas
npm init -y                       # crea package.json
npm install --save-dev eslint     # ESLint solo hace falta en desarrollo

Un detalle que conviene fijar desde el principio en package.json:

{
  "name": "nomada-tareas",
  "version": "1.0.0",
  "type": "module",
  "private": true,
  "scripts": {
    "lint": "eslint ."
  },
  "devDependencies": {
    "eslint": "^9.0.0"
  }
}
  • "type": "module" le dice a Node que los .js de este proyecto son módulos ES (05-04), no CommonJS. Sin esta línea, cualquier herramienta que ejecute tu código en Node se quejaría de los import.
  • "private": true evita publicar el paquete por accidente en npm.
  • devDependencies en lugar de dependencies: ESLint no forma parte de la aplicación, solo del proceso de desarrollo. El navegador nunca lo descarga.

Al ejecutar npm run lint sin configuración, ESLint avisa de que no encuentra fichero de configuración. Vamos a escribirlo.

  1. El fichero de configuración plano

ESLint moderno usa la configuración plana (flat config): un fichero eslint.config.js que exporta un array de objetos de configuración. Cada objeto dice a qué ficheros se aplica y qué reglas rigen ahí. Los objetos posteriores se superponen a los anteriores, como capas.

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';

export default [
  // ── Capa 0 · qué NO se analiza ────────────────────────────────────────
  {
    ignores: ['coverage/**', 'dist/**', 'node_modules/**']
  },

  // ── Capa 1 · base para TODO el JavaScript del proyecto ────────────────
  js.configs.recommended,

  // ── Capa 2 · el código de la aplicación: navegador + módulos ES ───────
  {
    files: ['js/**/*.js'],
    languageOptions: {
      ecmaVersion: 'latest',            // sintaxis más reciente (campos privados #, ?., ??=)
      sourceType: 'module',             // import/export, no require
      globals: {
        ...globals.browser              // window, document, fetch, localStorage, console…
      }
    },
    rules: {
      // aquí van las reglas propias del proyecto (apartado 7)
    }
  },

  // ── Capa 3 · el service worker vive en OTRO entorno global ────────────
  {
    files: ['sw.js'],
    languageOptions: {
      globals: { ...globals.serviceworker }   // self, caches, clients… pero NO document
    }
  },

  // ── Capa 4 · ficheros de herramientas que se ejecutan en Node ─────────
  {
    files: ['*.config.js', 'scripts/**/*.js'],
    languageOptions: {
      globals: { ...globals.node }            // process, __dirname (si aplica), console
    }
  }
];

Cinco cosas que entender de este fichero, porque son las que causan confusión:

  • Es JavaScript de verdad, no JSON. Puedes importar, calcular y componer. Por eso js.configs.recommended es un objeto que se inserta en el array.
  • El orden importa. Si dos capas definen la misma regla, gana la última. Por eso la configuración recomendada va arriba y tus ajustes debajo.
  • files decide el alcance. Un objeto sin files se aplica a todo. Esto es lo que permite la capa 3: sw.js no tiene document ni window, y sí tiene self y caches; declararlo evita cientos de falsos no-undef.
  • ignores en un objeto propio (sin files) actúa como ignorado global, el equivalente del antiguo .eslintignore.
  • globals es un paquete auxiliar con los catálogos de variables globales de cada entorno. Se instala aparte: npm i -D globals.

La capa 3 merece un comentario más. El service worker que escribiste en 07-05 se ejecuta en un hilo distinto con un objeto global distinto. Sin esa capa, ESLint marcaría self, caches y clients como no definidos, y —peor— no marcaría un uso accidental de document, que en un worker es un error real de ejecución. Configurar bien los entornos no es burocracia: es lo que convierte a no-undef en un detector de fallos de verdad.

  1. Reglas, niveles y configuraciones recomendadas

Cada regla de ESLint se configura con un nivel y, opcionalmente, opciones:

rules: {
  'no-unused-vars': 'error',                                   // nivel simple
  'no-console': ['warn', { allow: ['warn', 'error'] }],         // nivel + opciones
  'no-alert': 'off'                                             // desactivada
}
Nivel Valor numérico Qué ocurre Código de salida
'off' 0 La regla no se comprueba
'warn' 1 Se informa, pero no falla 0 (éxito)
'error' 2 Se informa y falla 1 (fallo)

La diferencia entre warn y error es crítica en cuanto añadas integración continua: error rompe la construcción, warn no. De ahí una estrategia muy práctica:

  • error para todo lo que sea un fallo real o un riesgo: no-undef, eqeqeq, no-debugger.
  • warn para lo que es preferencia o está en migración: reglas que acabas de activar sobre código existente y que todavía producen cien avisos.
  • Y una regla de higiene: los warn no pueden acumularse indefinidamente. Si un aviso lleva seis meses ahí, o se arregla o se apaga con un motivo escrito. Un lint que imprime 300 avisos es un lint que nadie lee.

js.configs.recommended activa alrededor de sesenta reglas que la comunidad considera imprescindibles y ninguna de estilo. Es deliberado: desde que Prettier existe, ESLint ha ido retirando las reglas de formato de su recomendación. Esa división de trabajo es el tema del apartado 12.

  1. globals: navegador, Node y pruebas

La regla no-undef marca cualquier identificador que no esté declarado. Es una de las más valiosas —habría cazado un tablro.resumen() al instante— pero solo funciona si ESLint sabe qué globales son legítimas en cada fichero.

import globals from 'globals';

// Navegador: window, document, fetch, localStorage, CustomEvent, AbortController…
globals.browser

// Service worker: self, caches, clients, skipWaiting…
globals.serviceworker

// Node: process, console, Buffer, URL…
globals.node

// Jest (lo necesitarás en 08-03): describe, test, expect, beforeEach…
globals.jest

Y si necesitas declarar una global propia —por ejemplo, una constante que inyecta el proceso de despliegue— se hace con su permiso de escritura explícito:

globals: {
  ...globals.browser,
  __VERSION_APP__: 'readonly'      // 'readonly' | 'writable' | 'off'
}

Marcarla como 'readonly' tiene un efecto extra: la regla no-global-assign avisará si alguien intenta reasignarla.

  1. Diez reglas que evitan bugs reales

Estas son las que justifican todo el montaje. No son cuestión de gusto: cada una corresponde a una familia de fallos que ha costado tardes a alguien.

Regla Qué detecta Ejemplo en Nómada Tareas
no-unused-vars Variables, parámetros e importaciones sin usar Un import { PESOS } que quedó tras un refactor: código muerto que confunde
no-undef Identificadores no declarados tablro.resumen(), un HOY sin importar
eqeqeq == y != en lugar de === / !== Justo la coerción de 01-07 que enmascaró el caso 1 de 08-01
no-implicit-globals Declaraciones que contaminan el objeto global Un var estado suelto en un script clásico
require-await Función async sin ningún await dentro async function guardar() que en realidad es síncrona: promete asincronía que no hay
no-return-await return await x innecesario dentro de un try… o fuera de él Un fotograma de pila extra sin ganancia
no-fallthrough Un case que cae al siguiente sin break El switch de estados de 02-03
no-cond-assign Asignación dentro de una condición if (tarea.estado = 'hecha') — cambia el estado y siempre es cierto
no-debugger La sentencia debugger Exactamente lo que te dejaste puesto en 08-01
no-constant-condition Condiciones siempre ciertas o siempre falsas `if (tarea.horasEstimadas

Y una undécima que merece explicación aparte porque es la más citada y la más malentendida: no-floating-promises.

Una promesa flotante es una promesa cuyo resultado nadie recoge:

// ¿Ves el fallo?
function alPulsarGuardar() {
  repositorio.guardar(tablero);
  actualizarTarea(tarea.id, { estado: 'hecha' });   // ← devuelve una promesa que nadie espera
  mostrarAviso('Guardado');                          // ← miente: aún no se ha guardado
}

Si actualizarTarea falla, el rechazo no lo captura nadie: se convierte en un unhandledrejection (05-06) y el usuario ve «Guardado» sobre un cambio que no llegó al servidor. Es un fallo silencioso de la peor especie.

El matiz importante: la regla no-floating-promises real requiere información de tipos, y esa la aporta TypeScript (apartado 18), no ESLint sobre JavaScript puro. En un proyecto solo-JavaScript se cubre con una combinación de aproximaciones (require-await, no-async-promise-executor, revisión) y, sobre todo, con la convención de equipo: toda llamada que devuelva una promesa se awaitea o se encadena con un .catch() explícito. Escribirlo en las convenciones es la mitad del trabajo; la otra mitad es que TypeScript pueda comprobarlo, y ahí es donde // @ts-check empieza a resultar atractivo.

  1. Desactivar una regla sin hacer trampas

Habrá casos legítimos en los que una regla se equivoca. ESLint permite silenciarla con precisión quirúrgica:

// Solo la línea siguiente
// eslint-disable-next-line no-console -- registro deliberado de diagnóstico (ver 08-01)
console.log('[nomada] modo diagnóstico activado');

// Solo esta línea, al final
const respaldo = almacenEnMemoria();   // eslint-disable-line no-unused-vars

// Un bloque completo
/* eslint-disable no-undef -- este fichero se ejecuta dentro del service worker */
self.addEventListener('install', …);
/* eslint-enable no-undef */

Cuatro normas de higiene con estos comentarios:

  • Nombra siempre la regla. Un // eslint-disable-next-line a secas apaga todas las reglas de esa línea, incluidas las que aún no existen.
  • Escribe el motivo tras --. ESLint admite esa sintaxis precisamente para eso, y evita que dentro de un año nadie sepa si sigue haciendo falta.
  • Prefiere disable-next-line a disable de bloque. Un bloque desactivado tiende a crecer y a tapar cosas nuevas.
  • Si la desactivas en cinco sitios, la regla está mal configurada. Cámbiala en eslint.config.js o apágala globalmente con un comentario que lo justifique. Cinco excepciones no son excepciones: son la norma real.

Además, la opción reportUnusedDisableDirectives (activa por defecto en la configuración recomendada moderna) avisa cuando un eslint-disable ya no hace falta porque el código cambió. Es limpieza automática de tus propias excepciones.

  1. --fix y los scripts del proyecto

Muchas reglas son autocorregibles: ESLint sabe reescribir el código para cumplirlas sin cambiar su significado.

npx eslint .              # solo informa
npx eslint . --fix        # arregla lo que puede e informa del resto
npx eslint . --fix-dry-run --format json    # simula, sin escribir

Qué se arregla solo y qué no:

Se corrige automáticamente Requiere criterio humano
Comillas, punto y coma, sangría no-unused-vars (¿sobra la variable o falta usarla?)
===== cuando es seguro no-undef (¿falta un import o hay una errata?)
letconst si nunca se reasigna require-await (¿sobra el async o falta un await?)
Orden de importaciones (con plugin) Complejidad excesiva

La regla mental: --fix resuelve la forma, nunca la intención. Todo lo que implique decidir qué querías hacer se queda para ti, y eso es correcto.

Los scripts que tendrá el proyecto a partir de aquí:

{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "verificar": "npm run lint && npm run format:check"
  }
}

verificar es el guion que ejecutará la integración continua, y el que puedes lanzar antes de cada commit mientras te acostumbras.

  1. Plugins útiles

Un plugin de ESLint aporta reglas nuevas. Estos tres cubren necesidades reales de un proyecto como este:

Importaciones. Un plugin de importaciones (eslint-plugin-import o su sucesor moderno eslint-plugin-import-x) comprueba lo que el navegador no perdona en módulos ES: rutas que no existen, extensiones olvidadas, dependencias circulares y orden de los import.

{
  files: ['js/**/*.js'],
  plugins: { import: importPlugin },
  rules: {
    'import/no-unresolved': 'error',        // la ruta './modelo/tarea.js' existe de verdad
    'import/extensions': ['error', 'always'], // en el navegador la extensión es OBLIGATORIA
    'import/no-cycle': 'error',              // A importa B, B importa A → orden de ejecución impredecible
    'import/order': ['warn', { 'newlines-between': 'always' }]
  }
}

import/extensions en 'always' es especialmente valioso aquí: los empaquetadores toleran import { Tarea } from '../modelo/tarea' sin extensión, pero el navegador con <script type="module"> no. Sin esta regla, el fallo aparece en tiempo de ejecución y solo en el navegador. Y import/no-cycle protege el grafo acíclico de dependencias que dibujaste en 05-04.

Accesibilidad. El plugin de accesibilidad más conocido (eslint-plugin-jsx-a11y) está pensado para JSX, que no usas. En un proyecto de HTML y DOM la comprobación equivalente se hace con otras herramientas: un validador de HTML, la auditoría de accesibilidad de las DevTools, y sobre todo axe, que en 08-06 integrarás en las pruebas de extremo a extremo. Merece la pena saber que existe la familia de reglas y por qué no encaja aquí: elegir un plugin porque suena bien y descubrir que no analiza tu tipo de fichero es una pérdida de tarde muy común.

Pruebas. eslint-plugin-jest detecta errores clásicos en las pruebas que escribirás en la lección siguiente: un test sin ninguna aserción, un expect fuera de un test, un test.only olvidado que deja el resto de la suite sin ejecutar —quizás el fallo más peligroso de todos, porque la suite sigue en verde mientras no comprueba nada—.

{
  files: ['**/*.test.js'],
  plugins: { jest: jestPlugin },
  languageOptions: { globals: { ...globals.jest } },
  rules: {
    'jest/no-focused-tests': 'error',     // test.only olvidado
    'jest/no-disabled-tests': 'warn',     // test.skip que lleva meses ahí
    'jest/expect-expect': 'error',        // una prueba sin aserciones no prueba nada
    'jest/valid-expect': 'error'
  }
}

  1. Prettier: el formato deja de ser una opinión

Prettier no es un linter: es un formateador determinista. Descarta por completo el formato original de tu código, lo reconstruye desde su árbol de sintaxis y lo imprime siguiendo sus propias reglas. Dos consecuencias enormes:

  • El resultado no depende de cómo estuviera escrito antes. Dos personas con estilos opuestos producen ficheros idénticos byte a byte.
  • Casi no hay que configurarlo. Prettier ofrece deliberadamente pocas opciones, para que la discusión no se traslade a la configuración.
npm install --save-dev prettier
// .prettierrc.json — la configuración completa de un proyecto como este
{
  "semi": true,
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2,
  "trailingComma": "none",
  "arrowParens": "always",
  "endOfLine": "lf"
}

Comentario de cada opción, porque son todas las que hay que decidir:

  • semi: punto y coma al final. Ponerlo evita la clase entera de sorpresas de la inserción automática (01-04).
  • singleQuote: comillas simples, coherente con todo el código del curso.
  • printWidth: 100: ancho máximo antes de partir. 80 es el clásico; 100 respira mejor en pantallas modernas y evita partir cadenas de .filter().map().reduce().
  • trailingComma: coma final. "none" mantiene el estilo del proyecto; "all" produce diffs más limpios al añadir elementos. Cualquiera de las dos vale: lo que no vale es que cada fichero use una.
  • endOfLine: "lf": fin de línea Unix. Sin esto, un equipo mixto Windows/macOS genera diffs completos por cambios invisibles.

Y un fichero de exclusiones:

# .prettierignore
node_modules/
coverage/
dist/
*.min.js

Uso:

npx prettier --write .      # formatea todo el proyecto
npx prettier --check .      # solo comprueba; falla si algo no está formateado (para CI)

El momento de adoptarlo. Ejecutar prettier --write . sobre un proyecto existente genera un commit gigantesco que toca todos los ficheros. Hazlo en un commit aislado que no cambie ni una línea de lógica, con un mensaje claro («formato: aplicar Prettier a todo el proyecto»). Y añade su hash a un fichero para que git blame lo ignore:

echo "d4e5f6a7b8c9  # formato: Prettier en todo el proyecto" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs

Con eso, git blame seguirá atribuyendo cada línea a quien escribió su lógica, no al commit de formateo. Es un detalle pequeño que ahorra mucha frustración futura.

  1. ESLint frente a Prettier, y cómo convivir

La confusión más habitual del ecosistema. Los dos leen tu código y los dos pueden modificarlo, pero responden a preguntas distintas:

ESLint Prettier
Pregunta que responde ¿Este código es correcto y seguro? ¿Este código está bien impreso?
Tipo de análisis Semántico: ámbitos, flujo, uso de variables Sintáctico: reimprime el AST
Ejemplo de lo que detecta Variable sin usar, ==, debugger Línea de 180 caracteres, comillas mezcladas
Configuración Extensa: cientos de reglas Mínima y deliberadamente limitada
¿Puede arreglar? Algunas reglas, con --fix Todo, siempre
¿Es opinable? Sí, cada regla se discute No: se acepta su criterio y se deja de discutir
Si lo quitas Aparecen bugs Aparecen discusiones

El conflicto y su solución. ESLint todavía incluye algunas reglas de formato heredadas (sangría, comillas…). Si las activas, ESLint y Prettier pueden pedir cosas contrarias y entrarás en un bucle: guardas, Prettier formatea, ESLint se queja; arreglas, Prettier lo deshace.

La solución estándar es eslint-config-prettier: una configuración que apaga todas las reglas de ESLint que chocan con Prettier. Se coloca la última del array, para que su desactivación gane:

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';

export default [
  js.configs.recommended,
  { files: ['js/**/*.js'], languageOptions: { globals: globals.browser }, rules: { /* … */ } },
  prettier                       // ← SIEMPRE el último: desactiva lo que chocaría
];

Existe también eslint-plugin-prettier, que ejecuta Prettier dentro de ESLint y reporta cada diferencia de formato como un error. Es cómodo (una sola herramienta) pero llena la salida de errores de formato mezclados con errores reales y ralentiza el análisis. La recomendación generalizada es la separación limpia: Prettier formatea, ESLint analiza, eslint-config-prettier los mantiene en sus carriles.

flowchart LR
    A["Guardas el fichero"] --> B["Prettier<br/>reimprime el formato"]
    B --> C["ESLint<br/>analiza la corrección"]
    C --> D{"¿Errores?"}
    D -->|No| E["Listo"]
    D -->|Sí| F["Los arreglas tú<br/>(o eslint --fix)"]
    F --> C

  1. Integración en el editor

Todo lo anterior se vuelve invisible —y por tanto útil— cuando el editor lo aplica solo.

// .vscode/settings.json — se versiona en el repositorio: todo el equipo igual
{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "eslint.useFlatConfig": true,
  "files.eol": "\n",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true
}

Qué hace cada línea: Prettier formatea al guardar; ESLint aplica sus correcciones automáticas también al guardar; los finales de línea y los espacios sobrantes se normalizan. El resultado práctico es que dejas de pensar en el formato: escribes como te salga, guardas y queda correcto.

Y un .editorconfig para quienes usen otro editor:

# .editorconfig
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

La última sección importa: en Markdown, dos espacios al final de línea significan salto de línea, así que recortarlos rompería el texto.

  1. Hooks de Git con Husky y lint-staged

El editor cubre a quien lo tiene bien configurado. El repositorio necesita una garantía que no dependa de eso. Los hooks de Git son guiones que Git ejecuta en momentos concretos; el que nos interesa es pre-commit, que corre antes de crear el commit y puede abortarlo.

  • Husky gestiona los hooks dentro del repositorio (Git no versiona .git/hooks, así que sin una herramienta cada persona tendría que instalarlos a mano).
  • lint-staged ejecuta las herramientas solo sobre los ficheros en el área de preparación (staged). Es lo que hace la diferencia entre un hook de dos segundos y uno de dos minutos.
npm install --save-dev husky lint-staged
npx husky init                       # crea .husky/ y el hook pre-commit
# .husky/pre-commit
npx lint-staged
// package.json
{
  "lint-staged": {
    "*.js": ["eslint --fix", "prettier --write"],
    "*.{json,css,md,html}": ["prettier --write"]
  }
}

El flujo completo, paso a paso:

flowchart TD
    A["git commit"] --> B["Husky ejecuta<br/>.husky/pre-commit"]
    B --> C["lint-staged toma<br/>SOLO los ficheros staged"]
    C --> D["eslint --fix<br/>prettier --write"]
    D --> E{"¿Quedan errores<br/>sin corregir?"}
    E -->|Sí| F["Commit ABORTADO<br/>con el listado"]
    E -->|No| G["Los arreglos se re-añaden<br/>al área de preparación"]
    G --> H["Commit creado"]

Detalle clave del paso G: lint-staged vuelve a añadir los ficheros que sus herramientas modificaron, así que el commit contiene el código ya formateado. No tienes que hacer nada.

Dos advertencias imprescindibles:

  • Un hook lento se desactiva. Si pre-commit tarda treinta segundos, alguien empezará a usar --no-verify y el guardián dejará de existir. Por eso lint-staged solo mira lo preparado, y por eso las pruebas completas no van en pre-commit: van en pre-push o directamente en integración continua.
  • --no-verify existe y a veces es legítimo (un commit de emergencia en producción a las tres de la mañana). Precisamente por eso el mismo control debe repetirse en el servidor, donde nadie puede saltárselo.

  1. Integración continua con GitHub Actions

La integración continua (CI) ejecuta las comprobaciones en un servidor limpio, en cada push y en cada pull request. Es la única capa que nadie puede eludir, y además elimina el clásico «en mi máquina funciona»: el servidor arranca desde cero, instala exactamente lo que dice el fichero de bloqueo y ejecuta lo mismo para todo el mundo.

# .github/workflows/calidad.yml
name: Calidad

# Cuándo se ejecuta
on:
  push:
    branches: [master]
  pull_request:

jobs:
  verificar:
    runs-on: ubuntu-latest          # máquina virtual limpia para cada ejecución

    steps:
      # 1 · Traer el código del repositorio a la máquina
      - name: Descargar el código
        uses: actions/checkout@v4

      # 2 · Instalar Node. 'cache: npm' reutiliza las dependencias entre ejecuciones
      - name: Preparar Node.js
        uses: actions/setup-node@v4
        with:
          node-version: lts/*
          cache: npm

      # 3 · Instalación reproducible: respeta package-lock.json al pie de la letra
      - name: Instalar dependencias
        run: npm ci

      # 4 · Análisis estático. Falla si hay errores (nivel 'error')
      - name: Analizar con ESLint
        run: npm run lint

      # 5 · Formato. No formatea: comprueba. Falla si algo no está formateado
      - name: Comprobar el formato
        run: npm run format:check

Puntos que conviene entender de este flujo:

  • npm ci en lugar de npm install. ci borra node_modules, instala exactamente las versiones del package-lock.json y falla si el lock no concuerda con el package.json. Es determinista; install puede actualizar el lock sobre la marcha.
  • node-version: lts/* usa la versión de soporte prolongado vigente, sin fijar un número que quedará obsoleto. Si tu proyecto necesita una versión concreta, decláralo en el campo engines de package.json y refléjalo aquí.
  • format:check, no format. En CI nunca se modifica el código: se comprueba y se falla. Corregir es trabajo del autor, en su máquina.
  • on: pull_request es lo que hace aparecer la marca verde o roja en la propuesta de cambios, antes de fusionar.

Cuando llegues a 08-03 añadirás un paso npm test a este mismo flujo, y en 08-06 otro con las pruebas de extremo a extremo. La estructura ya está lista.

Las tres capas de defensa, ordenadas por rapidez de respuesta:

Capa Cuándo actúa Se puede eludir Coste
Editor Al guardar Sí (no instalar la extensión) 0 s
Hook de Git Al hacer commit Sí (--no-verify) 1-3 s
Integración continua Al hacer push / PR No 30-90 s

Las tres son la misma comprobación repetida, y esa redundancia es deliberada: cuanto antes falla, más barato es arreglarlo.

  1. Convenciones que ninguna herramienta puede imponer

Automatizado el formato y el análisis, queda lo que exige criterio. Estas son las convenciones que merecen escribirse en un CONTRIBUTING.md del proyecto.

Nombres (retomando 01-04). El nombre es la documentación que nunca se desactualiza porque se lee en cada uso.

Elemento Convención En Nómada Tareas
Variables y funciones camelCase, en español, verbo si actúa horasAbiertas, crearBacklog(), pintarTarjeta()
Clases PascalCase, sustantivo Tarea, Tablero, RepositorioLocal, ErrorDeApi
Constantes de módulo MAYUSCULAS_CON_GUION HOY, PESOS, TRANSICIONES, EVENTOS
Campos privados # delante (05-03) #estado, #tareas, #almacen
Booleanos Prefijo interrogativo abierta, estaVencida(), persistente
Ficheros kebab-case.js, singular si exporta una cosa repositorio-local.js, tablero-vista.js
Manejadores al + evento, o manejar + cosa alCrear, manejarClic

Y tres antinombres que hay que erradicar: datos, info, gestionar. No dicen nada. datos puede ser cualquier cosa; tareasPendientes dice exactamente qué es.

Tamaño de funciones. No hay un número mágico, pero sí una prueba fiable: si necesitas un comentario para separar dos partes de una función, esas dos partes son dos funciones. Como referencia práctica: por encima de 30 líneas conviene mirarla con desconfianza, por encima de 50 casi seguro que hace dos cosas. Y el criterio decisivo no es la longitud sino el nivel de abstracción: una función que mezcla decidir qué renderizar con cómo escribirlo en el DOM ya está mal, tenga diez líneas o cien.

Comentarios que explican el porqué. El comentario que repite lo que dice el código es ruido que además envejece mal:

// ❌ Ruido: repite el código
// Incrementa el contador en uno
contador += 1;

// ❌ Peor: miente, porque el código cambió y el comentario no
// Devuelve las tareas ordenadas por fecha
return tareas.sort((a, b) => PESOS[b.prioridad] - PESOS[a.prioridad]);

// ✅ Útil: explica una decisión que el código no puede contar
// Devolvemos instancias nuevas en cada llamada para que dos consumidores
// (la aplicación y las pruebas) no compartan estado por accidente.
export function crearBacklog() { … }

// ✅ Útil: documenta una restricción externa
// El navegador NO distingue red caída de CORS bloqueado: ambos llegan como
// TypeError, deliberadamente, para no filtrar información sobre otros dominios.
throw new ErrorDeApi('No se pudo contactar con el servidor.', { codigo: 'red' });

// ✅ Útil: avisa de una trampa
// dataset SIEMPRE devuelve cadenas; sin Number() el === de buscarPorId falla.
const id = Number(li.dataset.id);

La regla: el código dice qué hace; el comentario dice por qué es así y no de otra manera. Si necesitas un comentario para explicar qué hace, normalmente el arreglo es renombrar o extraer una función, no comentar.

  1. Documentar tipos con JSDoc

JSDoc documenta tipos con comentarios estructurados. En JavaScript puro aporta dos cosas inmediatas: autocompletado y avisos en el editor (que entiende JSDoc de forma nativa), y documentación que se lee sin salir del fichero.

/**
 * Filtra y ordena las tareas para su presentación, sin modificar el tablero.
 *
 * @param {import('../modelo/tablero.js').Tablero} tablero  Fuente de datos
 * @param {object} opciones
 * @param {string|null} [opciones.responsable]  Nombre exacto, o null para no filtrar
 * @param {string} [opciones.texto='']          Búsqueda por título, sin distinguir mayúsculas
 * @param {'prioridad'|'fecha'|'horas'} [opciones.orden='prioridad']
 * @returns {import('../modelo/tarea.js').Tarea[]}  Array NUEVO; el tablero no se toca
 * @throws {ErrorDeValidacion} Si `orden` no es uno de los valores admitidos
 *
 * @example
 * tareasVisibles(tablero, { responsable: 'Iván' });   // → 3 tareas, 25 h
 */
export function tareasVisibles(tablero, { responsable = null, texto = '', orden = 'prioridad' } = {}) {
  // …
}

Y para tipos que se repiten, @typedef los define una vez:

/**
 * @typedef {object} ResumenTablero
 * @property {number} total          Todas las tareas del tablero
 * @property {number} abiertas       Las que no están en estado 'hecha'
 * @property {number} horasTotales   Suma de horasEstimadas de todas
 * @property {number} horasAbiertas  Suma de horasEstimadas de las abiertas
 * @property {number} vencidas       Abiertas con fechaLimite pasada (R10)
 * @property {number} esfuerzo       Suma de horas × peso de prioridad
 */

/**
 * @param {string} hoy  Fecha ISO de referencia
 * @returns {ResumenTablero}
 */
resumen(hoy) { … }

Ese @typedef documenta de una vez los números canónicos del proyecto y hace que el editor autocomplete resumen.horasAbiertas con su descripción al lado. En un proyecto de seis capas, eso vale más que cualquier documento externo, porque vive junto al código y se actualiza con él.

Consejo de dosificación: documenta con JSDoc las funciones exportadas —la superficie pública de cada módulo— y deja las internas con un buen nombre. Documentar todo produce ficheros donde hay más comentario que código y nadie lee ninguno.

  1. // @ts-check: comprobación de tipos sin TypeScript

Aquí está el siguiente escalón, y se puede subir sin reescribir nada. El compilador de TypeScript sabe analizar ficheros .js usando la información de JSDoc. Se activa con un comentario en la primera línea:

// @ts-check
import { Tarea } from './tarea.js';

/** @param {number} id */
export function buscar(id) { … }

buscar('7');
//     ~~~ Argument of type 'string' is not assignable to parameter of type 'number'.

Ese aviso es exactamente el caso 1 de la lección anterior, detectado en el editor, sin ejecutar nada, sin abrir el navegador y sin que Marta tenga que reportar nada. Un fallo que costó una investigación completa habría sido un subrayado rojo mientras se escribía.

Para activarlo en todo el proyecto sin poner el comentario fichero a fichero:

// jsconfig.json
{
  "compilerOptions": {
    "checkJs": true,
    "strict": true,
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "bundler",
    "noEmit": true
  },
  "include": ["js/**/*.js"]
}

"noEmit": true es lo esencial: TypeScript no genera ningún fichero, solo comprueba. Tu código sigue siendo JavaScript ejecutable tal cual, servido directamente al navegador.

La comparación honesta:

JavaScript + JSDoc + @ts-check TypeScript
Compilación necesaria No
Cobertura de tipos Buena, algo limitada en casos avanzados Completa
Verbosidad Alta (comentarios largos) Baja (sintaxis nativa)
Coste de adopción Muy bajo, fichero a fichero Medio-alto
Reglas como no-floating-promises Disponibles con el plugin de tipos Disponibles

Para Nómada Tareas, @ts-check es la opción sensata: cero cambios en el despliegue y una red que caza toda la familia de fallos de tipos. TypeScript entero es una decisión de proyecto que se trata en Siguientes Pasos, donde se sitúa dentro del mapa completo de lo que viene después de este curso.

  1. Métricas de calidad con cabeza

Existen métricas numéricas de calidad, y conviene conocer dos.

Complejidad ciclomática. Cuenta los caminos independientes de ejecución de una función: 1 de base, +1 por cada if, else if, case, for, while, catch, &&, || y ?:. Es, casi literalmente, el número mínimo de pruebas necesarias para recorrer todas las ramas, y por eso interesa aquí.

// Complejidad 1: un solo camino
export function marcaDeEstado(estado) {
  return MARCAS[estado] ?? '?';
}

// Complejidad 5: cuatro decisiones + base
function validarFormulario(formulario, datos, hoy) {
  const errores = [];
  for (const campo of formulario.elements) {              // +1
    if (campo.willValidate && !campo.checkValidity()) {   // +1 (if) +1 (&&)
      errores.push({ campo, mensaje: campo.validationMessage });
    }
  }
  if (datos.fechaLimite < hoy) errores.push(…);           // +1
  if (datos.etiquetas.length > 5) errores.push(…);        // +1
  return errores;
}

ESLint la mide con la regla complexity:

rules: {
  complexity: ['warn', { max: 10 }],
  'max-depth': ['warn', 4],           // anidamiento de bloques
  'max-lines-per-function': ['warn', { max: 60, skipComments: true, skipBlankLines: true }]
}

Como referencia orientativa: por debajo de 10, cómoda; entre 10 y 20, mirar si se puede dividir; por encima de 20, casi seguro que hay dos funciones dentro.

Deuda técnica. Es la metáfora, no una métrica: los atajos de hoy se pagan con intereses mañana en forma de tiempo de desarrollo. Algunas herramientas la expresan en horas estimadas de arreglo. Ese número es una estimación de una estimación; sirve para comparar la evolución de un mismo proyecto en el tiempo, no para comparar proyectos ni para presumir.

Y la advertencia que da título al apartado: no caigas en el fetichismo de los números. Tres patologías reales:

  • Perseguir la métrica en vez del objetivo. Bajar la complejidad partiendo una función en cuatro trozos incoherentes empeora el código y mejora el número.
  • Confundir "sin avisos" con "bien hecho". El caso 3 de 08-01 —horasAbiertas sumando 48 en lugar de 45— pasaba todas las reglas de ESLint. Un lint limpio no dice nada sobre si el programa es correcto.
  • Usar las métricas para evaluar personas. En cuanto un número se convierte en objetivo, deja de ser una buena medida (ley de Goodhart). Las métricas son un termómetro del código, no una nota.

  1. Nómada Tareas: configurarlo todo y arreglar los avisos

Montamos la configuración completa y vemos qué encuentra en el código real.

npm install --save-dev eslint @eslint/js globals eslint-config-prettier prettier husky lint-staged
npx husky init
// eslint.config.js — configuración completa del proyecto
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';

export default [
  { ignores: ['node_modules/**', 'coverage/**', 'dist/**'] },

  js.configs.recommended,

  // La aplicación: navegador + módulos ES
  {
    files: ['js/**/*.js'],
    languageOptions: {
      ecmaVersion: 'latest',
      sourceType: 'module',
      globals: { ...globals.browser }
    },
    rules: {
      // — Corrección —
      eqeqeq: ['error', 'always', { null: 'ignore' }],   // permite `x == null` (null y undefined a la vez)
      'no-undef': 'error',
      'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'no-implicit-globals': 'error',
      'require-await': 'error',
      'no-cond-assign': ['error', 'always'],
      'no-constant-condition': 'error',
      'no-fallthrough': 'error',

      // — Higiene —
      'no-debugger': 'error',                             // ← lo de 08-01
      'no-console': ['warn', { allow: ['warn', 'error'] }],
      'prefer-const': 'error',
      'no-var': 'error',
      'object-shorthand': 'warn',

      // — Tamaño —
      complexity: ['warn', { max: 12 }],
      'max-depth': ['warn', 4]
    }
  },

  // El service worker: otro global, otras reglas
  {
    files: ['sw.js'],
    languageOptions: { globals: { ...globals.serviceworker } },
    rules: { 'no-console': 'off' }        // en un SW, la consola es la única ventana
  },

  // Herramientas que corren en Node
  {
    files: ['*.config.js', 'scripts/**/*.js'],
    languageOptions: { sourceType: 'module', globals: { ...globals.node } },
    rules: { 'no-console': 'off' }
  },

  prettier                                 // ← el último, siempre
];

Y la primera ejecución sobre el código de los Módulos 1 a 7:

$ npm run lint

/nomada-tareas/js/vista/tablero-vista.js
   14:10  error    'PESOS' is defined but never used              no-unused-vars
   96:5   warning  Unexpected console statement                   no-console

/nomada-tareas/js/modelo/tablero.js
   52:9   error    Unexpected 'debugger' statement                no-debugger

/nomada-tareas/js/datos/api-tareas.js
   61:9   error    Expected '===' and instead saw '=='            eqeqeq
   88:1   error    Async method 'borrarTarea' has no 'await'      require-await

/nomada-tareas/js/vista/controlador.js
   38:15  error    'tarea' is not defined                         no-undef

/nomada-tareas/sw.js
   23:3   warning  Unexpected console statement                   no-console

✖ 7 problems (5 errors, 2 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.

Vamos uno por uno, porque cada arreglo tiene su matiz:

1 · 'PESOS' is defined but never used. Import huérfano de un refactor. Se borra la línea. Coste: cero. Beneficio: quien lea el fichero no buscará dónde se usa un peso que ya no se usa.

2 · Unexpected 'debugger' statement. El debugger de la lección anterior, que iba camino del repositorio. Este es el aviso que paga toda la configuración por sí solo: un debugger en producción congela la aplicación a cualquiera con las DevTools abiertas.

3 · Expected '===' and instead saw '=='.

// Antes
if (respuesta.status == 204) return null;
// Después
if (respuesta.status === 204) return null;

Aquí no había un fallo real (status siempre es número), pero la regla es de las que no admite excepciones caso a caso: mantener == en el código obliga a razonar cada vez si la coerción es segura. --fix lo corrige solo.

4 · Async method 'borrarTarea' has no 'await'. Este es un fallo de verdad:

// Antes — el async es una mentira: el error de red no lo captura nadie aquí
export async function borrarTarea(id) {
  fetch(construirUrl(`/tareas/${id}`), { method: 'DELETE' });   // ← promesa flotante
  return true;                                                   // ← miente siempre
}

// Después
export async function borrarTarea(id) {
  const respuesta = await fetch(construirUrl(`/tareas/${id}`), { method: 'DELETE' });
  await comprobar(respuesta);
  return true;
}

La versión original devolvía true antes de que el servidor contestara. Si el borrado fallaba con un 403, la interfaz eliminaba la tarjeta igualmente y el error se perdía como unhandledrejection. Una regla de tres palabras ha encontrado un fallo de coherencia de datos.

5 · 'tarea' is not defined. Una variable que se renombró en la mitad de las apariciones:

// Antes
const tareaPulsada = tablero.buscarPorId(id);
if (tarea.estado === 'hecha') return;      // ← 'tarea' ya no existe: ReferenceError

Este código lanzaba ReferenceError en ejecución, pero solo en la rama que casi nunca se recorre. ESLint lo ve sin ejecutar nada. Es el ejemplo perfecto de por qué no-undef merece nivel error.

6 y 7 · no-console. Registros de depuración de 08-01. Los del service worker están permitidos por la capa 3 (allí la consola es la única ventana). Los de la vista se sustituyen por el registrar() del ejercicio 2 de la lección anterior, que respeta niveles y no ensucia la consola en producción.

Resultado tras los arreglos:

$ npm run lint && npm run format:check
Checking formatting...
All matched files use Prettier code style!

Cinco errores, de los cuales dos eran fallos reales que nadie había notado: un borrado que mentía sobre su resultado y un ReferenceError latente. Ninguna de las dos habría aparecido en una revisión rápida, y ambas costaban menos de un minuto de configuración.

Errores Comunes y Consejos

  • Activar cientos de reglas de golpe en un proyecto existente. Salen 400 avisos, nadie los mira y la herramienta pierde toda credibilidad. Empieza por recommended más las diez reglas del apartado 7, deja el resto en warn y sube el listón cuando el suelo esté limpio.
  • Olvidar eslint-config-prettier, o ponerlo antes que tus reglas. Va el último del array. Si va antes, tus reglas de formato lo pisan y vuelve el bucle de guardar-formatear-quejarse.
  • No configurar globals por entorno. Sin ello, no-undef produce falsos positivos en sw.js (con self y caches) y en los ficheros de pruebas (con describe y expect), y la reacción típica —desactivar la regla— desarma el detector más útil que tienes.
  • Olvidar la extensión en los import. Los empaquetadores lo perdonan; el navegador de 05-04 no. Actívalo con import/extensions en 'always'.
  • // eslint-disable-next-line sin nombre de regla ni motivo. Apaga todas las reglas de esa línea, incluidas las futuras, y nadie sabrá si sigue siendo necesario.
  • Mezclar el commit de formateo con cambios de lógica. El diff se vuelve ilegible y la revisión se hace imposible. Formato en un commit aislado, y su hash en .git-blame-ignore-revs.
  • Hooks de Git tan lentos que la gente usa --no-verify. El pre-commit solo debe mirar los ficheros preparados. Las pruebas completas van a pre-push o a CI.
  • Creer que un lint limpio significa que el código funciona. El caso 3 de 08-01 pasaba todas las reglas. El análisis estático comprueba la forma; el comportamiento se comprueba con pruebas, y eso empieza en la lección siguiente.
  • Consejo: fija las versiones y usa npm ci en CI. Una actualización automática de ESLint puede activar reglas nuevas y poner en rojo un repositorio que nadie ha tocado.
  • Consejo: npx eslint . --max-warnings=0 convierte los avisos en errores para CI. Es la forma de impedir que los warn se acumulen sin tener que subirlos todos a error en el editor.
  • Consejo: escribe las convenciones en CONTRIBUTING.md. Lo que no está escrito se discute en cada revisión; lo que está escrito se cita en una línea.

Ejercicios

Ejercicio 1 — Configuración completa por entornos. Escribe el eslint.config.js de Nómada Tareas contemplando cinco entornos distintos: (a) js/**/*.js como módulos ES de navegador; (b) sw.js como service worker; (c) **/*.test.js con las globales de Jest y las reglas del plugin de pruebas; (d) cypress/**/*.js con cy, Cypress, describe e it como globales de solo lectura; (e) los ficheros de configuración en la raíz, que corren en Node. Justifica en comentarios por qué cada entorno necesita su capa y qué falso positivo evita.

Ejercicio 2 — Cazar los fallos con análisis estático. Para cada uno de estos fragmentos, indica qué regla lo detecta, si --fix puede arreglarlo, y cuál es la corrección correcta:

// A
export async function sincronizar(tablero) {
  repositorio.guardar(tablero);
  return { ok: true };
}

// B
function siguienteEstado(estado) {
  switch (estado) {
    case 'pendiente':
      return 'en-curso';
    case 'en-curso':
      registrar('info', 'cerrando');
    case 'hecha':
      return null;
  }
}

// C
if (tarea.horasEstimadas = 0) {
  throw new ErrorDeValidacion('Horas inválidas', 'horasEstimadas', 0);
}

// D
const abiertas = tablero.abiertas;
const cerradas = tablero.tareas.filter((t) => t.estado == 'hecha');
return cerradas.length;

Ejercicio 3 — Documentar el módulo con JSDoc y activar @ts-check. Toma js/modelo/tablero.js y añade documentación JSDoc completa: un @typedef para ResumenTablero con los seis campos, tipos para todos los métodos públicos (agregar, cambiarEstado, filtrar, resumen, horasPorResponsable), @throws donde corresponda y un @example con los números canónicos del backlog. Activa // @ts-check en el fichero y describe qué error señalaría el editor en cada uno de estos tres usos incorrectos: tablero.agregar({ id: 7, titulo: 'X' }), tablero.cambiarEstado('3', 'hecha') y tablero.resumen().

Soluciones

Solución 1

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';
import jest from 'eslint-plugin-jest';

export default [
  { ignores: ['node_modules/**', 'coverage/**', 'dist/**', 'cypress/videos/**', 'cypress/screenshots/**'] },

  js.configs.recommended,

  // (a) La aplicación. Globales del navegador: sin ellas, `document` y `fetch`
  //     dispararían no-undef en cada fichero de vista y de datos.
  {
    files: ['js/**/*.js'],
    languageOptions: {
      ecmaVersion: 'latest',
      sourceType: 'module',
      globals: { ...globals.browser }
    },
    rules: {
      eqeqeq: ['error', 'always', { null: 'ignore' }],
      'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'no-implicit-globals': 'error',
      'require-await': 'error',
      'no-debugger': 'error',
      'no-console': ['warn', { allow: ['warn', 'error'] }],
      'prefer-const': 'error',
      'no-var': 'error',
      complexity: ['warn', { max: 12 }]
    }
  },

  // (b) Service worker. Global distinto: existe `self`, `caches`, `clients`;
  //     NO existe `document`. Declararlo aquí hace que un uso accidental de
  //     `document` en el SW sí se marque como error, que es lo que queremos.
  {
    files: ['sw.js'],
    languageOptions: { sourceType: 'module', globals: { ...globals.serviceworker } },
    rules: { 'no-console': 'off' }
  },

  // (c) Pruebas. Sin globals.jest, `describe`, `test` y `expect` serían no-undef
  //     en cada fichero, y la reacción típica (apagar no-undef) desarmaría la regla.
  {
    files: ['**/*.test.js', 'pruebas/**/*.js'],
    plugins: { jest },
    languageOptions: { globals: { ...globals.jest, ...globals.node } },
    rules: {
      'jest/no-focused-tests': 'error',   // un test.only olvidado deja la suite sin ejecutar
      'jest/no-disabled-tests': 'warn',
      'jest/expect-expect': 'error',
      'jest/valid-expect': 'error',
      'no-console': 'off'                 // en una prueba, un console puntual es aceptable
    }
  },

  // (d) Cypress. `cy` y `Cypress` son globales inyectadas por el ejecutor;
  //     además el código de las pruebas E2E corre en el navegador.
  {
    files: ['cypress/**/*.js'],
    languageOptions: {
      globals: {
        ...globals.browser,
        cy: 'readonly',
        Cypress: 'readonly',
        describe: 'readonly',
        it: 'readonly',
        beforeEach: 'readonly',
        expect: 'readonly'
      }
    },
    rules: { 'no-unused-expressions': 'off' }   // el estilo .should() lo dispara en falso
  },

  // (e) Configuración y scripts: se ejecutan en Node, no en el navegador.
  //     `process` y `console` son legítimos aquí y no lo son en js/**.
  {
    files: ['*.config.js', 'scripts/**/*.js'],
    languageOptions: { sourceType: 'module', globals: { ...globals.node } },
    rules: { 'no-console': 'off' }
  },

  prettier
];

Solución 2

Caso Regla ¿--fix? Corrección
A require-await No La función es async sin await: o sobra el async, o falta esperar. Aquí falta: await repositorio.guardar(tablero) si es asíncrono, o quitar async. El fallo real es que { ok: true } se devuelve sin garantía de guardado
B no-fallthrough No Tras registrar(...) falta un return o un break: 'en-curso' cae en 'hecha' y devuelve null en vez del estado siguiente. Además, falta un default (regla default-case)
C no-cond-assign No Es = en lugar de ===. La condición asigna 0 a horasEstimadas (pasando por el setter, que además lanzaría por R3) y evalúa a 0, que es falso: la validación nunca se dispara. Correcto: if (tarea.horasEstimadas === 0)
D eqeqeq + no-unused-vars Sí para eqeqeq t.estado == 'hecha'===; y abiertas está declarada y no se usa: se borra la línea
// A · corregido
export async function sincronizar(tablero) {
  await repositorio.guardar(tablero);
  return { ok: true };
}

// B · corregido
function siguienteEstado(estado) {
  switch (estado) {
    case 'pendiente':
      return 'en-curso';
    case 'en-curso':
      registrar('info', 'cerrando');
      return 'hecha';
    case 'hecha':
      return null;
    default:
      throw new ErrorDeValidacion(`Estado desconocido: "${estado}".`, 'estado', estado);
  }
}

// C · corregido
if (tarea.horasEstimadas === 0) { … }

// D · corregido
return tablero.tareas.filter((t) => t.estado === 'hecha').length;

Solución 3

// @ts-check
import { Tarea } from './tarea.js';
import { ErrorDeValidacion } from './errores.js';

/**
 * Cifras agregadas del tablero en una fecha de referencia.
 *
 * @typedef {object} ResumenTablero
 * @property {number} total          Todas las tareas del tablero
 * @property {number} abiertas       Las que no están en estado 'hecha'
 * @property {number} horasTotales   Suma de horasEstimadas de todas
 * @property {number} horasAbiertas  Suma de horasEstimadas de las abiertas
 * @property {number} vencidas       Abiertas con fechaLimite pasada (R10)
 * @property {number} esfuerzo       Suma de horas × peso de prioridad
 */

export class Tablero {
  /** @type {Tarea[]} */
  #tareas = [];

  /**
   * @param {string} nombre
   * @param {Tarea[]} [tareas=[]]  Se agregan una a una, aplicando R1
   */
  constructor(nombre, tareas = []) { … }

  /**
   * Añade una tarea al tablero.
   * @param {Tarea} tarea  Instancia de Tarea, no un objeto plano
   * @returns {Tablero} el propio tablero, para encadenar
   * @throws {ErrorDeValidacion} si no es una Tarea, o si el id ya existe (R1)
   */
  agregar(tarea) { … }

  /**
   * Aplica una transición de estado a una tarea del tablero.
   * @param {number} id     Identificador numérico de la tarea
   * @param {'pendiente'|'en-curso'|'hecha'} nuevo
   * @returns {Tablero}
   * @throws {ErrorDeValidacion} si la tarea no existe o la transición viola R6
   */
  cambiarEstado(id, nuevo) { … }

  /**
   * @param {(tarea: Tarea) => boolean} predicado
   * @returns {Tarea[]} array nuevo; el tablero no se modifica
   */
  filtrar(predicado) { … }

  /**
   * @param {string} hoy  Fecha ISO 'yyyy-MM-dd' de referencia
   * @returns {ResumenTablero}
   *
   * @example
   * const tablero = new Tablero('Taller Nómada', crearBacklog());
   * tablero.resumen('2026-09-20');
   * // { total: 6, abiertas: 5, horasTotales: 48,
   * //   horasAbiertas: 45, vencidas: 1, esfuerzo: 124 }
   */
  resumen(hoy) { … }

  /**
   * Horas abiertas agrupadas por persona. Las tareas sin responsable (R8)
   * se agrupan bajo la clave 'sin asignar'.
   * @returns {Record<string, number>}
   *
   * @example
   * tablero.horasPorResponsable();   // { Iván: 25, Lucía: 14, Marta: 6 }
   */
  horasPorResponsable() { … }
}

Los tres errores que señalaría el editor con @ts-check:

1) tablero.agregar({ id: 7, titulo: 'X' })
   Argument of type '{ id: number; titulo: string; }' is not assignable to
   parameter of type 'Tarea'.  Type is missing the following properties: estado,
   abierta, esfuerzo, cambiarEstado…
   → Es exactamente lo que comprueba R1 en ejecución, pero antes de ejecutar.

2) tablero.cambiarEstado('3', 'hecha')
   Argument of type 'string' is not assignable to parameter of type 'number'.
   → El caso 1 de 08-01 (el id que llegaba como cadena), detectado en el editor.

3) tablero.resumen()
   Expected 1 arguments, but got 0.
   → Sin fecha, `estaVencida` recibiría undefined y `vencidas` daría 0 en silencio:
     un fallo mudo, del mismo tipo que el contador descuadrado del caso 3.

Conclusión

Nómada Tareas ha dejado de depender de la disciplina individual. Sabes qué es un analizador estático y dónde está su frontera: puede afirmar que una variable no se usa, que un identificador no existe, que un async no espera nada o que hay una asignación dentro de un if; no puede saber si horasAbiertas debe sumar 45 o 48. Por eso el análisis estático y las pruebas no compiten: cubren familias distintas de fallos, y un proyecto serio tiene ambos.

Tienes ESLint configurado de verdad: un eslint.config.js plano por capas, con files delimitando cada entorno —la aplicación en el navegador, el service worker con su global propio, Node para las herramientas—, globals bien declarados para que no-undef sea un detector real y no una fuente de falsos positivos, los tres niveles (off/warn/error) usados con criterio, y las diez reglas que evitan bugs reales: no-unused-vars, no-undef, eqeqeq, no-implicit-globals, require-await, no-fallthrough, no-cond-assign, no-debugger y las demás. Sabes silenciar una regla nombrándola y justificándola, sabes qué arregla --fix y qué no —la forma sí, la intención nunca—, y conoces los plugins que aportan valor: importaciones (con import/extensions en 'always', imprescindible en el navegador, e import/no-cycle para proteger el grafo de 05-04), pruebas (con no-focused-tests cazando el test.only olvidado) y por qué el plugin de accesibilidad de JSX no encaja en un proyecto de DOM puro.

Tienes Prettier como formateador determinista y entiendes por qué no compite con ESLint sino que se reparten el trabajo: uno responde «¿está bien impreso?» y el otro «¿es correcto?». Sabes juntarlos sin bucles con eslint-config-prettier colocado el último, adoptarlo en un commit aislado y neutralizarlo en git blame. Y tienes las tres capas de defensa montadas: el editor formateando y corrigiendo al guardar, un hook de pre-commit con Husky y lint-staged que solo mira los ficheros preparados para que nadie sienta la tentación de --no-verify, y un flujo de GitHub Actions con npm ci, npm run lint y npm run format:check que nadie puede eludir, listo para recibir el paso npm test de la próxima lección.

Y tienes lo que ninguna herramienta puede darte: las convenciones. Nombres que documentan (horasAbiertas, estaVencida(), RepositorioLocal, #estado) y los antinombres que hay que erradicar; el criterio de tamaño de función —si necesitas un comentario para separar dos partes, son dos funciones—; comentarios que explican el porqué y no repiten el qué; JSDoc documentando la superficie pública de cada módulo con @typedef ResumenTablero recogiendo los números canónicos; // @ts-check con jsconfig.json para cazar el id que llega como cadena mientras lo escribes, sin compilar ni cambiar el despliegue; y métricas —complejidad ciclomática, deuda técnica— usadas como termómetro y nunca como objetivo. La ejecución real encontró siete problemas y dos eran fallos genuinos: un borrarTarea que devolvía true sin esperar al servidor y un ReferenceError latente en una rama poco recorrida.

Pero fíjate en lo que ninguna de esas siete líneas mencionó: que el resumen del tablero debe dar 45 horas abiertas de 48, que la transición hecha → en-curso está prohibida por R6, que una tarea sin título debe lanzar ErrorDeValidacion, que el backlog canónico tiene un esfuerzo ponderado de 124. Eso no es forma, es comportamiento, y ninguna regla estática lo puede comprobar: hay que ejecutar el código con entradas conocidas y comparar la salida con lo esperado. Eso es una prueba automatizada, y ahí van las tres deudas que dejaste apuntadas en la lección anterior. En Pruebas Unitarias con Jest montarás el ejecutor, escribirás la batería completa de Tarea y Tablero —y descubrirás que aquella insistencia de 03-03 en las funciones puras y aquella frontera limpia entre modelo y vista que llevas seis módulos manteniendo eran, desde el principio, lo que iba a hacer posible probarlo todo sin abrir un navegador—.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados