Apuntes · Capítulo 12Calidad: pruebas, revisión e integración

Integración continua y dependencias

Integración continua y dependencias

La semana pasada endurecieron Kallpa con revisión de código y análisis estático. Todas esas herramientas tienen el mismo punto débil: alguien tiene que correrlas. make check no sirve de nada si quien abrió el pull request se olvidó de correrlo, o si lo corrió en su computadora, con otra versión de TypeScript, y nadie más lo vio. Esta semana hacemos que esa revisión deje de depender de la memoria de las personas.

El capítulo tiene dos partes, como la semana. En la primera estudiamos la integración continua: qué es como práctica, qué hace que un build sea confiable, cómo se construye un pipeline en GitHub Actions y qué hace falta para que ese pipeline impida algo, además de mostrar un color. En la segunda estudiamos las dependencias: el código que no escribieron y del que igual responden. Veremos versionado semántico, lockfiles, dependencias transitivas, cómo evaluar un paquete antes de agregarlo, los ataques a la cadena de suministro, los paquetes inventados por modelos de lenguaje y cómo leer un reporte de npm audit sin hacer una tontería.

Las dos partes se juntan en una sola idea: un build solo informa si sus entradas están bajo control. Las entradas de un build son el código de ustedes y todo el código de otros que entra con él. Hoy instalarán seis paquetes para revisar Kallpa y llegarán cien.

Lectura. Para la primera parte: Software Engineering at Google (SWEG), cap. 23, Continuous Integration, y cap. 18, Build Systems and Build Philosophy, hasta Modern Build Systems inclusive. Para la segunda: SWEG, cap. 21, Dependency Management, y Russ Cox, Our Software Dependency Problem (2019). Complementarias: Martin Fowler, Continuous Integration (2024); GitHub, documentación de GitHub Actions, en especial Workflow syntax y Secure use reference; Versionado semántico 2.0.0; npm, npm ci, package-lock.json y npm audit; Spracklen y otros, We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs (USENIX Security Symposium, 2025); SLSA y OpenSSF Scorecard. El código del capítulo está en code/week-12 y se prueba con make test dentro del contenedor del curso.

Todas las salidas de este capítulo son de corridas reales en el contenedor del curso, del 2 de noviembre de 2026. Las que dependen del registro de npm pueden no coincidir con lo que ustedes vean: el registro cambia todos los días. Esa es, justamente, parte de la lección.

Ruta de lectura.

  • Antes de clase (obligatorio, unos 20 minutos): de Qué es la integración continua, Una práctica, no una herramienta, Por qué esperar sale caro y Las reglas que la hacen funcionar; de Presubmit y post-submit, Qué corre antes de integrar; de GitHub Actions, Las piezas; de Una dependencia es código del que responden, Qué se está aceptando y Por qué es difícil; y de Versionado semántico, La convención y Una promesa, no una garantía.
  • En clase: Sesión A: Qué es la integración continua, El build, cuando no hay compilación, Presubmit y post-submit, GitHub Actions, Seguridad del pipeline y Un pipeline que no impide nada. Sesión B: Una dependencia es código del que responden, Versionado semántico, El lockfile, Dependencias transitivas, Antes de agregar una dependencia, La cadena de suministro, Paquetes que no existen y Leer npm audit con criterio. Laboratorio: El laboratorio: un pipeline para Kallpa.
  • Después de clase: el Resumen y los Ejercicios. Para profundizar: Reproducible y hermético, Pruebas inestables, El problema del diamante, peerDependencies, Defensas, y lo que no cubren y las lecturas del recuadro Lectura.

Qué es la integración continua

Una práctica, no una herramienta

Empecemos por la definición, porque casi todo el mundo la usa mal. Martin Fowler, en su artículo de 2024, escribe:

Continuous Integration is a software development practice where each member of a team merges their changes into a codebase together with their colleagues changes at least daily. Each of these integrations is verified by an automated build (including test) to detect integration errors as quickly as possible.

En español: la integración continua es una práctica en la que cada integrante integra su trabajo a la rama principal al menos una vez al día, y cada integración se verifica con un build automático que incluye las pruebas.

Tiene dos partes, y las dos son obligatorias:

  1. Integrar seguido. Es una decisión del equipo: cambios chicos que entran a main todos los días.
  2. Verificar cada integración. Es un servidor que corre un build.

La mayoría de la gente cree que integración continua es la segunda. Fowler lo dice sin rodeos cuando alguien le pregunta si se puede correr un servicio de CI sobre feature branches: “yes - but you’re not doing Continuous Integration”. Lo que hacen esas herramientas, dice, se llamaría mejor continuous build service. Son una ayuda útil, y no hay que confundir la herramienta con la práctica.

Por eso, un equipo con un pipeline perfecto y ramas que viven tres semanas no hace integración continua. Tiene un build automático, que ya es algo, pero se entera de los choques entre su trabajo y el de los demás tres semanas tarde.

Idea clave. La integración continua no se instala. El pipeline verifica cada integración; integrar todos los días lo decide el equipo. Sin lo segundo, lo primero solo verifica ramas aisladas.

SWEG parte de esa misma definición, en una versión más antigua que dice integrate their work frequently, y resume el objetivo en una frase: detectar los cambios problemáticos automáticamente y lo antes posible. Después propone una definición más amplia, pensada para sistemas enormes: the continuous assembling and testing of our entire complex and rapidly evolving ecosystem. Es decir, no solo su código cambia: también cambian los servicios a los que llaman, los datos, el sistema operativo y las bibliotecas. Por eso las dos partes de este capítulo van juntas. Las dependencias son una de esas cosas que cambian sin pedirles permiso.

Por qué esperar sale caro

SWEG ordena los lugares donde se puede detectar un error, del más barato al más caro:

  1. En el ciclo de edición de quien lo escribe.
  2. En las pruebas automáticas antes de integrar (presubmit).
  3. Después de integrar, cuando se prueban dos cambios juntos (post-submit).
  4. En un ambiente de staging.
  5. Con usuarios internos.
  6. Con usuarios reales, o en la prensa.

Cada paso siguiente cuesta más por tres razones, según el capítulo: el error lo investiga alguien que no conoce el cambio; quien lo escribió ya no se acuerda de los detalles; y el problema ya afecta a otras personas. La palabra que usa SWEG es feedback loop, y la integración continua es la manera de acortarlo.

La misma lógica vale para el tiempo entre integraciones. Si cada uno integra cada dos horas, un choque con el trabajo de otra persona se descubre con dos horas de cambios sospechosos. Si integra cada dos semanas, con dos semanas.

Dos ramas en verde, un merge en rojo

¿Pueden chocar dos cambios que no tocan las mismas líneas? Sí, y es el caso más incómodo, porque Git no avisa de nada. Fowler los llama conflictos semánticos, y SWEG, mid-air collisions (choques en el aire), cuando ocurren mientras las pruebas de un cambio están corriendo.

Lo armamos en una copia de Kallpa con make colision. Desde el mismo commit salen dos ramas:

  • renombra-soles cambia el nombre de formatSoles a centsToSoles en pricing.ts y en todas las líneas donde se usa: notifier.ts y test/pricing.test.ts.
  • linea-de-caja agrega a report.ts una función nueva que llama a formatSoles, con su nombre de siempre.

Cada una por separado está perfecta. Al juntarlas:

--- rama renombra-soles
make check: código de salida 0
--- rama linea-de-caja
make check: código de salida 0
--- las dos juntas

$ git merge --no-edit linea-de-caja
Merge made by the 'ort' strategy.
 src/domain/report.ts | 6 ++++++
 1 file changed, 6 insertions(+)
[código de salida: 0]

$ make check
tsc -p .
src/domain/report.ts(1,10): error TS2305: Module '"./pricing.ts"' has no exported member 'formatSoles'.
make: *** [Makefile:28: typecheck] Error 2
[código de salida: 2]

Git hizo el merge sin una sola queja: los archivos que tocó cada rama no se pisan. Las dos revisiones pudieron aprobar con razón, y los dos pipelines salieron en verde con razón. Lo que está roto es la combinación, y nadie la había probado.

Animación en cuatro pasos. Paso 1: de un mismo commit salen dos ramas: renombra-soles, que cambia formatSoles por centsToSoles en pricing.ts, notifier.ts y test/pricing.test.ts, y linea-de-caja, que agrega a report.ts una función que llama a formatSoles. Paso 2: make check da 0 en cada una. Paso 3: las dos líneas se juntan en un merge, y git merge da código 0, sin conflictos. Paso 4: el commit de merge se pone rojo: make check da código 2, con el error TS2305 en report.ts, porque pricing.ts ya no exporta formatSolesAnimación en cuatro pasos. Paso 1: de un mismo commit salen dos ramas: renombra-soles, que cambia formatSoles por centsToSoles en pricing.ts, notifier.ts y test/pricing.test.ts, y linea-de-caja, que agrega a report.ts una función que llama a formatSoles. Paso 2: make check da 0 en cada una. Paso 3: las dos líneas se juntan en un merge, y git merge da código 0, sin conflictos. Paso 4: el commit de merge se pone rojo: make check da código 2, con el error TS2305 en report.ts, porque pricing.ts ya no exporta formatSoles
Dos ramas en verde y un merge limpio, y aun así el resultado no compila. Git compara líneas, no lo que significan.

De aquí salen dos conclusiones. La primera: mientras menos tiempo vivan las ramas, menos combinaciones sin probar hay y más fácil es encontrar cuál fue, porque, como dice Fowler, solo hay unas pocas horas de cambios donde buscar. La segunda: algo tiene que probar el resultado del merge, no solo cada rama por su lado. Volveremos a esto cuando veamos la protección de ramas.

Las reglas que la hacen funcionar

El artículo de Fowler enumera las prácticas una por una. Estas son las que importan esta semana:

Práctica (nombre en Fowler) Qué quiere decir En Kallpa
Put everything in a version controlled mainline Un repositorio y una rama principal, con todo lo necesario para construir main, con el Makefile, el tsconfig.json, la configuración de ESLint y, desde hoy, el lockfile
Automate the Build Un solo comando make check
Make the Build Self-Testing El build incluye las pruebas make check corre node --test
Everyone Pushes Commits To the Mainline Every Day Integrar todos los días Pull requests chicos, como en la semana 11
Every Push to Mainline Should Trigger a Build Lo verifica una máquina, siempre El workflow de hoy
Fix Broken Builds Immediately Nada es más urgente que el rojo Arreglar o revertir
Keep the Build Fast Minutos Las 40 pruebas de Kallpa tardan décimas de segundo
Everyone can see what’s happening El resultado está a la vista de todos La pestaña Checks del pull request

Tres comentarios sobre esa tabla.

Fix Broken Builds Immediately. Fowler cita a Kent Beck: “nobody has a higher priority task than fixing the build”. No significa que todo el equipo se detenga; suele bastar con un par de personas. Y aclara que lo mejor, muchas veces, es revertir el commit que lo rompió y arreglarlo con calma. SWEG dice lo mismo: revertir suele ser la ruta más rápida y segura, porque deja el sistema en un estado conocido. En Google la plataforma de pruebas ya revierte sola algunos cambios cuando tiene mucha certeza de que son los culpables.

Keep the Build Fast. Fowler usa como guía la de Extreme Programming: un build de diez minutos. No es una ley, y él mismo lo llama guideline, pero marca de qué hablamos: si el build tarda una hora, nadie espera el resultado antes de pasar a otra cosa, y el rojo llega cuando la persona ya no se acuerda del cambio.

Un build que pasa “casi siempre” no da información. Si el rojo puede significar “se rompió algo” o “volvió a pasar lo de siempre”, la gente aprende a ignorarlo. Lo veremos con números en la sección de pruebas inestables.

En la industria. SWEG cuenta el caso de Google Assistant. Cuando corrían en presubmit pruebas que dependían de servicios reales, las pruebas fallaban rutinariamente, y había días en que más de 50 cambios pasaban por encima de los resultados sin hacerles caso. Al volverlas herméticas redujeron el tiempo de corrida en un factor de 14, prácticamente sin inestabilidad. El costo no desapareció: las pruebas no herméticas se fueron a post-submit, sus fallas se acumulan ahí, y algunos equipos simplemente las desactivan. Es un solo equipo y un relato del propio Google, no un experimento. Sirve como ejemplo de un mecanismo, no como medida de cuánto van a ganar ustedes. (SWEG, cap. 23.)

Error común. Decir “hacemos CI porque tenemos GitHub Actions”. Pregunten dos cosas: ¿cuántas veces al día entra código a main?, ¿cuánto vive una rama? Si la respuesta es “una vez por sprint” y “dos semanas”, tienen un pipeline, y eso es bueno, pero todavía no tienen integración continua.

El build, cuando no hay compilación

Qué es un build

SWEG, en el capítulo 18, dice que un sistema de build transforma el código en algo ejecutable, y que uno bueno optimiza dos propiedades:

  • Rápido. Un solo comando, y el resultado en poco tiempo.
  • Correcto. Cada vez que cualquiera corre el build en cualquier máquina, obtiene el mismo resultado, si las entradas son las mismas.

Kallpa plantea una duda legítima: Node ejecuta TypeScript directamente. Quita los tipos al cargar y corre. No hay ningún paso que genere JavaScript, así que ¿qué es “el build”? La respuesta del capítulo 23 de SWEG ayuda: romper el build incluye romper las pruebas, no solo la compilación. En un proyecto como este, el build es lo que tiene que pasar para decir que un cambio está bien. En Kallpa son tres cosas, y son exactamente lo que corre make check:

check: typecheck lint test

typecheck:  tsc -p .
lint:       eslint .
test:       node --test "test/**/*.test.ts"

En la semana 13 se agrega la imagen de contenedor, y ahí sí habrá un artefacto que construir.

Que no haya compilación vuelve más importante este paso. Miren este cambio (make tipos): en notifier.ts, a formatSoles le pasamos un string en lugar de un number.

$ make test | tail -8
ℹ tests 40
ℹ suites 6
ℹ pass 40
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 188.724542

$ make typecheck
tsc -p .
src/notifications/notifier.ts(21,146): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
make: *** [Makefile:28: typecheck] Error 2
[código de salida: 2]

Las 40 pruebas pasan. Node quitó los tipos sin mirarlos, y dentro de formatSoles la cuenta cents / 100 convierte el texto en número, así que el resultado es el mismo. El programa corre, el servidor levanta y el mensaje sale bien. La única línea de defensa contra ese error es que alguien corra tsc. Si el build no incluye tsc, los tipos son comentarios.

Idea clave. Sin un paso de compilación, nada obliga a que los tipos sean ciertos. make check es el compilador que no tienen, y el pipeline es lo que garantiza que corra.

Reproducible y hermético

Dos palabras que se usan mucho:

  • Un build es reproducible si con las mismas entradas da el mismo resultado, en cualquier máquina y en cualquier momento.
  • Un build es hermético si no depende de nada que no haya declarado: ni de lo que esté instalado en la máquina, ni de la zona horaria, ni de la red, ni de otro servicio.

Lo segundo es una forma de conseguir lo primero. SWEG usa hermético sobre todo para las pruebas (cap. 23: pruebas que corren contra un ambiente autocontenido, sin dependencias externas). El capítulo 18 trata las mismas ideas con otras palabras: las herramientas son dependencias, con versión declarada; las acciones del build corren aisladas, viendo solo sus entradas; y las dependencias externas se fijan con un hash, porque si un archivo remoto cambia sin que cambie el código, “a build might work one day and fail the next for no obvious reason”. Su resumen del capítulo lo dice de forma tajante: depender de la “última” versión es una receta para el desastre y para builds que no se pueden reproducir.

“En mi computadora pasa” casi siempre significa que algo no estaba fijado. Este es un ejemplo mínimo. Una función calcula la fecha de una reserva con getDate(), que usa la zona horaria de la máquina, y su prueba se escribió en Lima (make zona):

$ TZ=America/Lima node --test ejemplos/fecha-local.check.ts | grep -E '^(✔|✖) la fecha|^ℹ (pass|fail)'
✔ la fecha según la máquina (0.489541ms)
✔ la fecha en Lima (0.185209ms)
ℹ pass 2
ℹ fail 0

$ TZ=UTC node --test ejemplos/fecha-local.check.ts | grep -E '^(✔|✖) la fecha|^ℹ (pass|fail)' | head -4
✖ la fecha según la máquina (1.120167ms)
✔ la fecha en Lima (0.20975ms)
ℹ pass 1
ℹ fail 1

Las 21:00 del viernes en Lima ya son sábado en UTC. La prueba pasa en todas las laptops del equipo y falla en el pipeline, porque los runners de GitHub están en UTC. Nadie miente: las dos máquinas dan el resultado correcto para lo que se les preguntó. La zona horaria era una entrada del build y nadie la había declarado.

Kallpa ya estaba preparada para esto. clock.ts usa la zona America/Lima de forma explícita (por eso “la fecha en Lima” pasa en las dos), y las pruebas usan un reloj fijo y una base de datos :memory:. No fue casualidad.

Revisemos todas las entradas del build de Kallpa, porque el resultado es honesto pero no perfecto:

Entrada ¿Fijada? Cómo
El código Sí El commit
TypeScript, ESLint, fast-check, tipos Desde hoy Versión exacta en package.json
Los otros 94 paquetes Desde hoy Versión y hash en package-lock.json
Hora y zona horaria en las pruebas Sí fixedClock y limaTime
Base de datos Sí :memory:, se crea en cada prueba
Servicios externos Sí Kallpa no llama a ninguno
Versión de Node A medias En el workflow: node-version: 26 acepta cualquier 26.x
Sistema operativo del runner A medias ubuntu-latest es una etiqueta que se mueve: el repositorio de las imágenes anuncia que pasa a Ubuntu 26.04 en noviembre de 2026
El registro de npm No npm ci lo necesita disponible, aunque los hashes aseguren qué baja

Dos de esas filas son decisiones razonables para un proyecto como este: fijar todo cuesta trabajo, y actualizar a mano la versión del sistema operativo tiene su propio riesgo. Lo importante es saber qué no está fijado, para que cuando el pipeline se ponga rojo un lunes sin que nadie haya tocado nada, la lista de sospechosos esté escrita.

Sistemas de build: tareas y artefactos

El capítulo 18 de SWEG recorre la historia de los sistemas de build, y vale la pena conocer la distinción de Modern Build Systems, aunque no la usemos.

Un sistema basado en tareas (Ant, Maven, Gradle, y también Make, que es el de Kallpa) define tareas: scripts que hacen lo que quieran y que dependen de otras tareas. Es muy flexible. El problema, dice SWEG, es que da demasiado poder a los scripts: como una tarea puede hacer cualquier cosa, el sistema no sabe qué lee ni qué escribe. Por eso le cuesta correr tareas en paralelo, le cuesta saber qué volver a construir, y los propios scripts se vuelven código con errores que nadie revisa.

Un sistema basado en artefactos (Blaze en Google, Bazel, Pants, Buck) le quita ese poder a los ingenieros. En vez de decir cómo se construye, se declara qué hay que construir y a partir de qué. El sistema decide los pasos, y como conoce todas las entradas de cada paso, puede aislarlos, repetirlos y guardar sus resultados.

SWEG lo presenta como una necesidad de escala: su ejemplo es un monorepo de miles de millones de líneas. El Makefile de Kallpa tiene siete objetivos y no necesita nada de eso. Pero las lecciones se trasladan: declarar las herramientas, aislar lo que se pueda y no descargar versiones “últimas” valen también para un proyecto pequeño.

La regla: el pipeline llama a lo mismo

Hay una regla de la que depende todo lo demás en este capítulo:

Idea clave. El pipeline llama a lo mismo que corre una persona en su computadora, no a una copia con otros comandos.

Si el workflow corre tsc --noEmit && eslint src && node --test y su Makefile corre otra cosa, tienen dos definiciones de “está bien”, y un día van a discrepar: el pipeline pasará y su laptop no, o al revés. Se perderá una tarde en entender por qué. Con make check en los dos lados, hay una sola definición. Si mañana agregan un paso, lo agregan en un solo lugar.

Esa regla tiene un costo, y lo vamos a pagar hoy: en el contenedor, tsc está en el PATH; en un runner, no está. Por eso los primeros pasos del workflow no son la revisión, sino preparar el ambiente para que make check funcione igual.

Presubmit y post-submit

Qué corre antes de integrar

En Google, presubmit son las pruebas que corren antes de integrar un cambio, y post-submit las que corren después. Es la decisión central de un sistema de CI: SWEG resume el capítulo diciendo que un sistema de CI decide qué pruebas usar y cuándo.

¿Por qué no correr todo antes de integrar? SWEG da tres razones:

  1. Es caro. El tiempo de quien espera es valioso, y las pruebas pasan mucho más seguido de lo que fallan, así que la mayor parte de esa espera no aporta.
  2. La inestabilidad bloquea. Una falla que no tiene que ver con el cambio detiene a alguien sin que haya hecho nada mal.
  3. No alcanza de todas maneras. Mientras corren las pruebas, el repositorio puede cambiar: es el choque en el aire que vieron arriba.

Su regla práctica para presubmit: only fast, reliable ones. A cambio, se acepta algo de pérdida de cobertura antes de integrar, se atrapa lo demás después y se acepta que habrá que revertir algunas cosas. Después de integrar pueden correr pruebas más lentas o menos estables, si hay mecanismos para manejarlas.

En la industria. TAP, la plataforma de pruebas de Google, hace justo eso. Antes de integrar corre las pruebas asociadas al cambio; después corre en forma asíncrona todo lo que el cambio podría afectar, incluidas las pruebas grandes y lentas. Como no puede correr todo para cada cambio, agrupa varios, y cuando un grupo falla, usa dos caminos para encontrar al culpable: separa el grupo y vuelve a probar cada cambio, y además da herramientas para hacer una búsqueda binaria entre ellos. Su norma cultural desalienta con fuerza integrar trabajo nuevo encima de pruebas que se sabe que fallan. (SWEG, cap. 23.)

En GitHub, esto se traduce en un workflow que corre en el evento pull_request (presubmit) y otro, o el mismo, en push a main (post-submit). Un equipo de ustedes tiene pocas pruebas y rápidas, así que todo cabe en las dos. El día que tengan pruebas de punta a punta que tarden veinte minutos, ya tienen el criterio para decidir dónde van.

¿Para qué correr de nuevo en push a main, si ya pasó en el pull request? Porque lo que se integró puede no ser lo que se probó: si main avanzó desde que corrió el check, el resultado del merge es otra combinación. Es el caso de las dos ramas de arriba.

Arriba, seis lugares donde se puede detectar un error, del más barato al más caro: al editar, presubmit, post-submit, staging, usuarios internos y usuarios reales. Más adelante, lo investiga alguien que no conoce el cambio, quien lo escribió ya no se acuerda y ya afecta a otras personas. Abajo, presubmit es el panel antes de integrar, con el evento pull_request: solo pruebas rápidas y confiables, y prueba el commit de merge con el main que había cuando corrió. Una flecha de merge lleva a post-submit, el panel después de integrar, con push a main: pueden correr pruebas lentas o menos estables, y si falla se arregla o se revierte. Los dos corren make check, y sin la protección de rama, el botón de merge funciona igual con el check en rojoArriba, seis lugares donde se puede detectar un error, del más barato al más caro: al editar, presubmit, post-submit, staging, usuarios internos y usuarios reales. Más adelante, lo investiga alguien que no conoce el cambio, quien lo escribió ya no se acuerda y ya afecta a otras personas. Abajo, presubmit es el panel antes de integrar, con el evento pull_request: solo pruebas rápidas y confiables, y prueba el commit de merge con el main que había cuando corrió. Una flecha de merge lleva a post-submit, el panel después de integrar, con push a main: pueden correr pruebas lentas o menos estables, y si falla se arregla o se revierte. Los dos corren make check, y sin la protección de rama, el botón de merge funciona igual con el check en rojo
Un error sale más caro mientras más tarde se encuentra. Presubmit lleva lo rápido y confiable; post-submit prueba lo que de verdad quedó en main.

Pruebas inestables

Una flaky test es una prueba que a veces pasa y a veces falla sin que nada haya cambiado. Hagamos la cuenta. Si cada prueba falla sola con probabilidad p, y las fallas son independientes, la probabilidad de que un build con n pruebas salga en rojo sin que nadie haya roto nada es:

P = 1 - (1 - p)^n

Con las 40 pruebas de Kallpa y un 1 % de falla en cada una, P da 33.1 %: uno de cada tres builds saldría en rojo sin razón (flaky.ts). Una sola prueba con 5 % de falla, a lo largo de 20 builds, da un 64.2 % de ver al menos un rojo falso. La independencia es una simplificación, porque en la práctica las fallas suelen venir juntas (cuando se cae un servicio, caen todas), pero el orden de magnitud alcanza para ver el problema.

La consecuencia no es técnica: es de conducta. Después de varios rojos falsos, el equipo aprende que el rojo no significa nada, vuelve a correr el job hasta que pase, y el día que el rojo sea real, también lo volverá a correr. Por eso una prueba flaky en presubmit puede ser peor que no tenerla: no solo no informa, sino que enseña a ignorar a las demás. SWEG dice que erosionan la confianza igual que una prueba rota, con una dificultad extra: como no fallan siempre, cuesta encontrar qué cambio revertir.

¿Qué se hace? En orden:

  1. Sacarla del presubmit mientras se investiga, con una tarea asignada. SWEG cuenta que algunos equipos tienen herramientas que lo hacen solas.
  2. Encontrar la causa. Casi siempre es una entrada no declarada: la hora, la red, el orden de las pruebas, datos que deja otra prueba.
  3. Volver a correr varias veces es una táctica real, y SWEG la menciona. Pero úsenla sabiendo lo que compra: esconde el síntoma, hace más lento el build y puede esconder un error de verdad en el código, que también puede ser intermitente.

Error común. Agregar reintentos automáticos “para que el pipeline deje de molestar” sin abrir un issue. La inestabilidad sigue ahí, ahora invisible, y un error de concurrencia real en su aplicación se verá igual que una prueba mala.

Con IA. Un agente al que se le pide “haz que el pipeline pase” encuentra rápido el camino corto: subir un timeout, agregar un reintento, marcar la prueba con skip o cambiar lo que comprueba. Son cambios como los que suele proponer un agente, y en el diff parecen inofensivos. Cuando un pull request toca una prueba que estaba fallando, la pregunta de la revisión no es si ahora pasa. Es por qué fallaba, y dónde está la explicación.

CI es una forma de alerta

SWEG incluye un recuadro de Titus Winters con una comparación que ayuda: CI y las alertas de producción sirven para lo mismo, que es identificar problemas de forma automática y lo antes posible. CI lo hace en el extremo temprano, con pruebas que fallan. Las alertas, al final, con métricas. Y las dos comparten las mismas buenas prácticas: una prueba, como una alerta, debe fallar solo cuando se viola algo que importa. La veremos de nuevo en la semana 13, con SLO y on-call.

GitHub Actions

Las piezas

GitHub Actions es el servicio de CI que viene con GitHub. Sus conceptos:

Concepto Qué es En un archivo
Workflow Un proceso automático: un archivo YAML en .github/workflows/ El archivo completo
Evento Lo que lo dispara: abrir un pull request, un push, una hora on:
Job Un conjunto de pasos que corren en una misma máquina jobs:
Runner La máquina: una máquina virtual nueva cada vez, que se borra al terminar runs-on:
Step Un paso, en orden, dentro de un job steps:
Comando Un step que corre en la shell run:
Action Un step reutilizable que otra persona ya escribió uses:

Los jobs corren en paralelo, cada uno en su propio runner, salvo que uno declare needs: con el otro. Los steps de un job corren en secuencia, y si uno falla, los siguientes no corren. Un step falla si su comando sale con un código distinto de cero. Toda la lógica de un pipeline descansa en esa convención del sistema operativo.

Un primer intento

Este workflow está en code/week-12/ejemplos/workflow-incompleto.yml:

# Primer intento: la forma está bien, pero en un runner no llega a correr nada.
name: CI

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 26
      - run: make check

Línea por línea:

  • on: tiene dos eventos: cada pull request (por omisión, cuando se abre, se sincroniza o se reabre) y cada push a main.
  • permissions: limita el GITHUB_TOKEN, la credencial temporal que recibe el workflow. Volveremos a esto en la sección de seguridad.
  • check es un id que eligen ustedes. Es también el nombre con el que aparecerá el check si no le ponen name:, y es lo que elegirán en la protección de rama.
  • actions/checkout descarga el código. Un runner nuevo está vacío: sin este step no hay nada que revisar. Un detalle importante: en un pull_request, la documentación de GitHub dice que lo que se prueba es el commit de merge del pull request (refs/pull/N/merge), no la punta de su rama. O sea que el check ya prueba “la rama junto con main”, tal como estaba main cuando corrió.
  • actions/setup-node instala la versión de Node que se le pide.
  • make check es el build. El mismo comando de siempre.

¿Por qué no llega a correr nada? Porque el runner no tiene el contenedor del curso. Según la lista de lo que trae la imagen, tiene muchas herramientas generales, entre ellas Git y make, pero no tiene tsc ni ESLint.

No podemos correr un runner de GitHub desde el apunte, pero sí su ambiente, con el PATH que tendría:

$ env PATH=/usr/local/bin:/usr/bin:/bin make check
tsc -p .
make: tsc: No such file or directory
make: *** [Makefile:28: typecheck] Error 127
[código de salida: 2]

Faltan dos cosas, y el laboratorio les pide agregarlas:

  1. Instalar las herramientas. El repositorio tiene que declararlas en package.json, y el job tiene que instalarlas con npm ci. Por eso es la única semana del curso en la que se instala algo con npm.
  2. Que make las encuentre. npm ci deja los ejecutables en node_modules/.bin, que no está en el PATH. make llama a tsc por su nombre. GitHub tiene un archivo especial, $GITHUB_PATH: lo que se escribe ahí se agrega al PATH de los siguientes steps del job.

Con las dos cosas, el mismo ambiente sí pasa:

$ env PATH="$PWD/node_modules/.bin:/usr/local/bin:/usr/bin:/bin" make check | tail -9
✔ una barra al final no cambia la ruta (0.079958ms)
ℹ tests 40
ℹ suites 6
ℹ pass 40
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 169.690666

setup-node tiene además la opción cache: npm. Su documentación aclara que no guarda node_modules, sino la caché global de paquetes de npm, y usa el hash del lockfile como llave. npm ci igual borra y reconstruye node_modules, pero no vuelve a bajar lo que ya tiene.

Un job colgado no falla

Un step falla cuando su comando sale con un código distinto de cero. Un proceso colgado no sale con ningún código: no falla, solo sigue esperando. Y la documentación de GitHub dice que un job se cancela por omisión a los 360 minutos: seis horas con el pull request detenido y la máquina ocupada, sin un mensaje de error que leer.

Por eso el job del laboratorio declara su propio límite, bastante mayor que lo que tarda normalmente:

jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 10

No es un caso teórico. tsc termina llamando a process.exit(), y Node tiene un bug abierto: si en ese momento un hilo de V8 espera una recolección de basura que el hilo principal ya no va a hacer, cada uno se queda esperando al otro. El proceso queda vivo, sin usar CPU. Al preparar este curso, tsc sobre Kallpa se colgó 2 veces en 1200 corridas, con 20 contenedores compitiendo por la máquina. Ese número mide esa carga, no lo que pasará en un runner: lo que importa es que el problema es raro y, sin un límite, caro. El contenedor del curso lo evita (env/Dockerfile), pero en el runner se usa el tsc de node_modules.

Error común. Subir el límite porque el job “a veces no alcanza”. Si un job que dura dos minutos llega a los diez, algo está mal: es mejor que la falla se vea y se investigue.

Lo que el contenedor esconde

La simulación de arriba tiene un agujero, y conviene conocerlo porque les puede hacer perder una tarde. En el contenedor del curso, tsc deja de estar si le cambiamos el PATH, pero los import siguen encontrando los paquetes de la imagen en /node_modules, porque Node busca subiendo de carpeta en carpeta hasta la raíz.

Probémoslo. Quitamos @eslint/js de package.json, aunque eslint.config.js lo importa, y corremos make check en el contenedor (make falta):

$ npm uninstall --no-audit --no-fund @eslint/js

removed 1 package in 162ms
[código de salida: 0]

$ make check | tail -8
ℹ tests 40
ℹ suites 6
ℹ pass 40
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 177.576542

Todo verde, con un paquete sin declarar. Lo mismo en la imagen oficial de Node, sin las herramientas del curso. Eso se corre en la terminal de la computadora, donde está Docker, con sh runner-limpio.sh @eslint/js. En esa copia todavía no hay node_modules, y por eso npm uninstall dice que agregó los otros 99 paquetes:

$ npm uninstall --no-audit --no-fund @eslint/js | grep -E "^(added|removed)"
added 99 packages in 2s
$ node --version; which tsc
v26.13.0
/tmp/kallpa/node_modules/.bin/tsc
$ make check | grep -v -E "^ *(✔|▶)" | cut -c1-110
tsc -p .
eslint .

Oops! Something went wrong! :(

ESLint: 10.14.0

Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@eslint/js' imported from /tmp/kallpa/eslint.config.js
...
make: *** [Makefile:31: lint] Error 2
[código de salida: 2]

Este es el “en mi computadora pasa” en estado puro, y aquí la computadora es la imagen del curso. Por eso el pipeline da información que el contenedor no puede dar: es la primera vez que el proyecto se construye solo con lo que declaró.

Con todas las herramientas declaradas, en esa misma imagen limpia, sh runner-limpio.sh termina bien: npm ci instala 100 paquetes y make check pasa las 40 pruebas, con código de salida 0. Es lo más cerca que se puede llegar de un runner sin GitHub. Esa corrida tardó 2 segundos en npm ci, en esta máquina y con una buena conexión: no es lo que tardará en un runner. En el laboratorio medirán el suyo, y es la única cifra que les importa.

El código de salida manda

Como un step falla solo si su comando sale con un código distinto de cero, hay varias formas de escribir un pipeline que no impide nada, sin darse cuenta.

Una tubería puede esconder la falla. La documentación de GitHub dice que un run: sin shell: corre con bash -e {0}, mientras que con shell: bash explícito corre con bash --noprofile --norc -eo pipefail {0}. Sin pipefail, el código de salida de una tubería es el de su último comando. Comprobemos la diferencia:

$ bash -e -c 'false | tee /tmp/log; echo sigue'
sigue
[exit 0]

$ bash -eo pipefail -c 'false | tee /tmp/log; echo sigue'
[exit 1]

Entonces, run: make check | tee check.log, sin más, siempre pasa: tee termina bien aunque make check haya fallado. Se ve rojo en el log y verde en el check, y es el tipo de pipeline que tarda meses en descubrirse.

continue-on-error: true. Un step o un job con esa opción no hace fallar el workflow. Tiene usos legítimos, como probar una versión nueva de Node sin bloquear a nadie, pero en el job principal convierte al pipeline en decoración.

Un comando que nunca devuelve error. npm test || true, por ejemplo. Lo verán más seguido de lo que creen, normalmente con un comentario que dice “por ahora”.

Error común. Probar un pipeline solo cuando pasa. El laboratorio les pide romperlo a propósito (paso 5) porque un check que nunca vieron en rojo es un check que no saben si funciona. Es la misma idea de la semana 10: una prueba que no puede fallar no prueba nada.

Un revisor de workflows

revisar-workflow.ts lee un workflow y lo compara con las reglas de esta semana, sin correrlo. Pueden pasárselo al suyo. Con el primer intento de arriba:

$ node revisar-workflow.ts ejemplos/workflow-incompleto.yml
error  npm-ci: Falta npm ci: en el runner no están las herramientas.
error  path: Falta agregar node_modules/.bin al PATH: make no encontraría tsc.
aviso  fijar: actions/checkout@v7 usa una etiqueta, que se puede mover; lo más seguro es el SHA del commit.
aviso  fijar: actions/setup-node@v7 usa una etiqueta, que se puede mover; lo más seguro es el SHA del commit.
2 errores, 2 avisos

Es análisis estático, con los límites de la semana 11: revisa la forma del archivo y no dice si el workflow funciona. Eso solo lo dice el runner.

Seguridad del pipeline

Un pipeline es un programa que corre automáticamente, con credenciales, sobre código que todavía nadie aprobó, y que además baja y ejecuta código de terceros. Visto así, conviene pensarlo con cuidado. Tres reglas cubren la mayoría de los casos, y las tres están en la referencia de seguridad de GitHub.

Permisos mínimos

Cada job recibe un GITHUB_TOKEN, una credencial temporal para operar sobre el repositorio. Un job de revisión solo necesita leer el código, y por eso se declara:

permissions:
  contents: read

Es el principio de mínimo privilegio. Si un paquete malicioso llega a correr dentro del job, solo puede leer lo que ya era legible. Con permissions: write-all, podría subir commits al repositorio.

Cuando el pull request viene de un fork, GitHub ya protege algo: según la documentación, los secrets no se pasan al runner (salvo el GITHUB_TOKEN) y el token es de solo lectura. Por eso existe la tentación de usar el evento pull_request_target, que corre con los permisos de la rama base. Es peligroso justamente por eso: si ese workflow hace checkout del código del fork y lo ejecuta, un desconocido corre su código con sus credenciales.

Los secrets

Los secrets (tokens, contraseñas) van en la configuración de GitHub y nunca en el código ni en el workflow. GitHub los tapa en los logs, pero la documentación aclara que la redacción automática no está garantizada, porque busca coincidencias exactas: un secret transformado (en base64, dentro de un JSON) puede pasar sin taparse. Si uno llega a un log, se borra el log y se cambia el secret. En este laboratorio no usan ninguno; en P4, el token de despliegue será el primero.

Fijar las actions

uses: actions/checkout@v7 parece una versión fija, pero v7 es una etiqueta de Git, y quien controla el repositorio puede moverla a otro commit. No es hipotético.

En la industria. El 14 y 15 de marzo de 2025, alguien modificó las etiquetas de la action tj-actions/changed-files, de la v1 a la v45.0.7, para que apuntaran a un commit con código malicioso. Quienes tenían @v45 en su workflow corrieron ese código sin haber cambiado nada. El efecto, según el registro CVE-2025-30066, fue que los atacantes podían descubrir secrets leyendo los logs de actions. En un repositorio público, esos logs los puede leer cualquiera.

Lo que no se mueve es el SHA completo del commit. La documentación de GitHub recomienda fijar ahí: para meter una puerta trasera sin cambiar el SHA, un atacante tendría que generar una colisión de SHA-1. Para encontrarlo:

$ git ls-remote https://github.com/actions/checkout 'refs/tags/v7*'
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7
9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0	refs/tags/v7.0.0
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1

Y se escribe con la versión en un comentario, para que un humano sepa qué es:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

¿Se ve la lista? v7 y v7.0.1 apuntan hoy al mismo commit. Mañana v7 apuntará a otro, porque esa etiqueta se mueve a propósito con cada release. Una etiqueta anotada listaría primero el objeto de la etiqueta; en ese caso, usen el SHA de la línea que termina en ^{}, que es el del commit.

Fijar al SHA tiene un costo: ya no reciben actualizaciones solas, y hay que actualizar a mano o con una herramienta como Dependabot. Por eso el laboratorio pide como mínimo la versión mayor, con el cómo se fijaría al SHA en un comentario. Es una decisión de riesgo, y una action oficial de GitHub no es lo mismo que una de un desconocido.

Una colección de errores

ejemplos/workflow-peligroso.yml junta todo lo que no hay que hacer, con un comentario en cada línea: permisos de más, una action sin fijar, npm install, un secret en un echo, una tubería que esconde el error. Léanlo entero, porque cada línea parece razonable por separado. revisar-workflow.ts le encuentra 11 errores.

Un pipeline que no impide nada

Ya tienen un workflow que corre. Falta lo más importante. En un repositorio sin configurar, el botón de merge funciona igual con el check en rojo. El pull request se integra “porque era urgente”, y main queda rota. El pipeline es decorativo hasta que alguien lo convierte en una regla.

En GitHub, esa regla es la protección de rama. En Settings → Branches (o en Rules) se configura para main:

  • Require a pull request before merging, con al menos una aprobación. Nadie sube directo a main.
  • Require status checks to pass before merging, eligiendo el check de su job.

La documentación de GitHub tiene tres detalles que conviene saber:

  1. Por omisión, las restricciones no se aplican a los administradores. Quien tenga permisos de admin puede pasar por encima, salvo que se active Do not allow bypassing the above settings. En un equipo de estudiantes, todos suelen ser admin, así que la regla sin esta opción protege menos de lo que parece.
  2. Modo estricto o relajado. Con Require branches to be up to date before merging activado (estricto), la rama tiene que estar al día con main antes del merge. Hace correr más builds. Sin esa opción (relajado), hay menos builds, pero las pruebas pueden pasar en la rama y romperse después del merge.
  3. Disponibilidad. La protección está en repositorios públicos con GitHub Free, y en públicos y privados con GitHub Pro, Team y Enterprise. GitHub Pro es gratis con GitHub Education.

El segundo detalle es la respuesta a make colision. En modo relajado, nuestras dos ramas se integran en verde, una detrás de otra, y main queda en rojo. En modo estricto, la segunda rama queda desactualizada cuando entra la primera: tiene que traer los cambios, su check vuelve a correr, sale en rojo y el error se descubre antes del merge. El precio es volver a correr el build de todos los pull requests abiertos cada vez que uno entra. Con pocos pull requests al día, es barato. Para equipos grandes, GitHub ofrece una cola de merge (Require merge queue), y SWEG describe la misma idea: los proyectos chicos pueden evitar los choques en el aire integrando los cambios de a uno.

Es decir, el check del pull request no prueba “lo que va a quedar”: prueba la combinación con el main que había cuando corrió. Para ramas abiertas varios días y un main que no se detiene, eso cuenta.

Idea clave. Un pipeline da información. La protección de rama la convierte en una regla. Sin required status checks, un check en rojo es una opinión.

Con IA. El pipeline atrapa lo que un agente rompió sin darse cuenta, y por eso un agente no recibe permiso para saltarse los checks ni para cambiar el workflow sin revisión. Las razones son las de siempre, con más fuerza: un agente con la meta de “que quede en verde” tiene incentivos para desactivar una prueba, y, como vimos en la semana 1, puede seguir instrucciones escondidas en lo que lee. Cualquier cambio en .github/workflows/ se revisa como un cambio de seguridad: en ese archivo está quién puede correr qué, con qué credenciales. Un CODEOWNERS sobre esa carpeta lo hace obligatorio.

Una dependencia es código del que responden

Qué se está aceptando

Empezamos la segunda parte. Russ Cox define el problema con una frase incómoda:

A package, for this discussion, is code you download from the internet. Adding a package as a dependency outsources the work of developing that code—designing, writing, testing, debugging, and maintaining—to someone else on the internet, someone you often don’t know.

Cuando agregan una dependencia, sus errores, sus vulnerabilidades y sus cambios son ahora de ustedes. Si se cae producción a las tres de la mañana por una biblioteca, el usuario no va a leer la licencia, que dice que el autor no da ninguna garantía. Agregar una dependencia toma una línea y unos segundos. Mantenerla, años.

¿Por qué lo hacemos, entonces? Porque suele valer la pena. Nadie debería escribir su propio analizador de TypeScript. La pregunta correcta es qué paquete vale su costo. Cox lo plantea como un cálculo de riesgo: el costo esperado de una dependencia es la suma, sobre todo lo que puede salir mal, de cuánto cuesta multiplicado por su probabilidad. En un proyecto de fin de semana, el costo de casi todo es cero. En un sistema que cobra dinero y debe durar años, no.

Por qué es difícil

SWEG dedica el capítulo 21 a esto, y Titus Winters lo llama uno de los problemas menos entendidos de la ingeniería de software. Lo compara con el control de versiones. Los dos tratan de cómo se trabaja con código, pero la gestión de dependencias agrega complejidad en tiempo y en escala:

  • Tiempo. El cambio ocurre fuera de su organización, sin aviso y sin que ustedes puedan correr las pruebas de quien lo hace. Quien publica no sabe que ustedes existen.
  • Escala. No se trata de un paquete, sino de una red. Con el tiempo, todos los nodos tendrán versiones nuevas, algunas importantes, y hay que encontrar una combinación en la que todas funcionen juntas.

Su mejor consejo es casi una broma, pero no lo es: “prefer source control problems to dependency management problems”. Lo que controlan ustedes se arregla con un commit. Lo que no, se negocia.

SWEG cuenta también cómo sale mal en Google. Alice necesita un paquete de código abierto para una demo y lo importa a la carpeta third_party, con Bob como segundo responsable. Otros equipos empiezan a usarlo sin que ellos se enteren. Pasan los años, Bob cambia de puesto y Alice de equipo. Un día hay una vulnerabilidad y hay que actualizar, miles de proyectos dependen de él, y nadie lo conoce ni practicó esa actualización. Google no se pone de ejemplo: el capítulo resume cómo importa dependencias con un “in short: we could do better”. Una dependencia que entra por una demo y se queda para siempre es el caso típico, y esa deuda se cobra de golpe.

Kallpa no tiene ninguna, a propósito

Kallpa no tiene ninguna dependencia de producción: node:http, node:sqlite y node:test alcanzan. No es un capricho ni una regla universal. Es una decisión sobre una aplicación chica, de vida larga, para fines de enseñanza. Sus herramientas de desarrollo (TypeScript, ESLint) sí son paquetes, y hoy los declaramos.

La distinción importa, y volverá varias veces:

  • dependencies: llegan a producción. Corren con sus datos y sus usuarios.
  • devDependencies: solo existen en desarrollo y en el pipeline. Ojo, que corren en las máquinas del equipo y en el runner, con sus credenciales. Riesgo diferente no significa riesgo cero.

Versionado semántico

La convención

El versionado semántico (semver) dice que una versión tiene tres números, MAJOR.MINOR.PATCH, con este significado:

Sube Cuando Ejemplo
MAJOR Se rompe la compatibilidad 10.14.0 → 11.0.0
MINOR Se agrega algo compatible 10.14.0 → 10.15.0
PATCH Se corrige algo, de forma compatible 10.14.0 → 10.14.1

La especificación tiene una excepción que importa: mientras MAJOR es 0, cualquier cosa puede cambiar. La API todavía no se considera estable.

Una promesa, no una garantía

Semver es una promesa de quien publica. Nada la comprueba. SWEG va más lejos y la llama una estimación con pérdida (a lossy estimate): una persona decide cuánto riesgo cree que tiene su cambio y lo comprime en un número. El problema es que después el package manager toma esa estimación como un hecho absoluto.

El capítulo describe dos maneras de fallar:

  • Promete de más (overpromise). La ley de Hyrum, que conocen desde la semana 1, dice que con suficientes usuarios, todo comportamiento observable tendrá a alguien que dependa de él. Cambiar el orden de una salida “sin orden” o el formato de un log es un PATCH para quien lo publica y una rotura para quien dependía de eso. Como dice SWEG, un cambio no es “compatible” en abstracto: lo es o no según cómo lo use cada uno.
  • Restringe de más (overconstrain). Una biblioteca sube de MAJOR porque rompió una función que ustedes no usan. El package manager igual les dirá que no se puede actualizar, aunque todo funcionaría bien.

Un ejemplo con Kallpa. Supongamos que typescript-eslint publica un PATCH que arregla un falso negativo: una regla recomendada ahora también detecta un caso que antes dejaba pasar. Es una corrección, compatible según semver: la API no cambió. Pero eslint . empieza a dar un error en un archivo que ayer pasaba, make check sale en rojo y su pull request no había tocado ese archivo. Es la ley de Hyrum: dependían de que esa regla no viera ese caso, sin haberlo decidido.

Lo que da evidencia de verdad, concluye SWEG, son las pruebas y CI: “testing and CI provide actual evidence of whether a new set of versions work together”. Semver dice qué espera quien publica. Su pipeline dice qué pasó.

Los rangos

En package.json no se escribe solo una versión, sino un rango de las que se aceptan. semver-range.ts implementa las tres formas más comunes:

Rango Acepta No acepta
8.72.1 Solo 8.72.1 8.72.2
~8.72.1 8.72.1 a 8.72.x 8.73.0
^8.72.1 8.72.1 a 8.x.y 8.72.0, 9.0.0
^0.23.5 0.23.5 a 0.23.x 0.24.0
^0.0.3 Solo 0.0.3 0.0.4

Las dos últimas filas son las que casi todos se olvidan. El ^ deja fijo el primer número distinto de cero. Antes de la 1.0, un MINOR puede romper cosas, así que ^ no cruza al siguiente MINOR. Y no es un caso de libro: npm explain minimatch muestra que eslint pide @eslint/config-array@"^0.23.5".

Las pruebas de semver-range.test.ts comparan esa implementación con el paquete semver, el que usa npm de verdad, en 5000 casos generados con fast-check. Es una prueba diferencial, como las de la semana 10.

Si no escriben nada especial, npm install eslint guarda ^10.14.0. Por eso, el laboratorio usa --save-exact.

El mismo archivo, dos resultados

¿Qué tan grave es un rango? Hagamos el experimento (make rangos). Un package.json con una sola línea, "eslint": "^10.0.0", sin lockfile. npm install --before instala lo que habría instalado en esa fecha:

$ npm install --no-audit --no-fund --before=2026-03-01

added 68 packages in 1s
[código de salida: 0]

$ npm ls eslint --depth=0
rangos@ /work/code/week-12/build/rangos/antes
`-- eslint@10.0.2

[código de salida: 0]

$ npm install --no-audit --no-fund

added 77 packages in 1s
[código de salida: 0]

$ npm ls eslint --depth=0
rangos@ /work/code/week-12/build/rangos/hoy
`-- eslint@10.14.0

[código de salida: 0]

El mismo archivo, sin que nadie lo haya tocado, da 68 paquetes en marzo y 77 en noviembre, con otra versión de ESLint. Si una persona del equipo instaló en marzo y otra hoy, no están corriendo lo mismo. Con rangos, package.json describe un conjunto de instalaciones posibles, no una.

El lockfile

Qué guarda

La solución es el lockfile. package-lock.json registra lo que resultó de resolver los rangos: la versión exacta y el hash de cada paquete instalado, también los transitivos. Esta es la entrada real de minimatch, que nadie pidió por su nombre:

"node_modules/minimatch": {
  "version": "10.2.6",
  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
  "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
  "dev": true,
  "license": "BlueOak-1.0.0",
  "dependencies": {
    "brace-expansion": "^5.0.8"
  },
  ...
}
  • version dice qué versión es.
  • resolved dice de dónde se baja.
  • integrity es un hash del archivo que se bajó. Si llega otra cosa, se detiene.

Fíjense en lo que esto implica para el --save-exact del laboratorio. Las seis directas quedaron exactas en package.json, pero eso no fija las otras 94: minimatch se pide con ^10.2.4, ^10.2.2 y ^10.2.6. Solo el lockfile fija todo el árbol. El lockfile entra al repositorio; node_modules/, no.

npm install y npm ci

npm install npm ci
Para qué Agregar o cambiar dependencias Reproducir lo que dice el lockfile
¿Modifica package-lock.json? Puede Nunca
Si no hay lockfile Lo crea Falla
Si package.json y el lockfile no coinciden Resuelve y actualiza el lockfile Falla
node_modules existente Lo actualiza Lo borra antes de instalar

En el pipeline siempre va npm ci. Una precisión, porque se suele contar mal: npm install no actualiza versiones cada vez que corre. Si hay un lockfile que satisface package.json, lo respeta. El peligro es que resuelve el problema en silencio cuando no coinciden. Justo eso se quiere evitar en un pipeline, donde una diferencia entre los dos archivos debe ser un error y no una decisión automática.

Ahora veámoslo fallar, que es para lo que está (make lockfile). Alguien edita package.json y se olvida del lockfile:

$ npm ci
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Invalid: lock file's fast-check@4.10.2 does not satisfy fast-check@4.10.1
...
[código de salida: 1]

Si no hay lockfile, falla con el mismo código, EUSAGE, y pide generarlo.

Y si el hash del lockfile no coincide con lo que llega del registro (cambiamos a mano el hash de ms y borramos la caché, para simular un archivo que cambió):

$ npm ci 2>&1 | grep -E 'code EINTEGRITY|integrity checksum' | cut -c1-110
npm error code EINTEGRITY
npm error sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw== inte

Los tres son buenos resultados. El pipeline se detuvo en vez de construir otra cosa.

En la industria. Cox cuenta que en 2018, la última versión del paquete event-stream resultó traer código malicioso, ofuscado, que llevaba dos meses y medio publicado. Quien lo metió no pudo cambiar en silencio la versión 3.3.5, que ya existía: los hashes lo habrían detectado. Tuvo que publicar una versión nueva y esperar a que la gente actualizara sin mirar los cambios, y escondió el código en otro paquete, flatmap-stream, agregado como dependencia nueva. Los hashes cumplieron y el ataque igual funcionó. (Our Software Dependency Problem.)

Lo que el lockfile no hace

Ese recuadro deja ver el límite. Un hash garantiza que lo que instalan hoy es lo mismo que instalaron la primera vez. No dice nada sobre si lo de la primera vez era bueno. El lockfile protege contra cambios, no contra malas decisiones, y cada vez que alguien lo actualiza, vuelve a confiar en lo que llegue.

Error común. Borrar package-lock.json y correr npm install para “arreglar” un conflicto de merge en ese archivo. Eso actualiza todo el árbol a lo último que acepten los rangos, en un commit que nadie va a revisar. En un conflicto de lockfile, resuelvan package.json y dejen que npm install reconstruya solo lo que hace falta, sobre el lockfile de main. Revisen el diff.

Dependencias transitivas

Seis se vuelven cien

Las versiones directas del laboratorio son seis, las mismas que trae la imagen (ver env/Dockerfile). Al instalarlas:

$ npm install --save-dev --save-exact typescript@6.0.3 @types/node@26.6.6 eslint@10.14.0 @eslint/js@10.0.1 typescript-eslint@8.72.1 fast-check@4.10.2

added 100 packages, and audited 101 packages in 37s
...
$ npm ls
kallpa@1.0.0 /work/code/week-12/build/arbol
+-- @eslint/js@10.0.1
+-- @types/node@26.6.6
+-- eslint@10.14.0
+-- fast-check@4.10.2
+-- typescript-eslint@8.72.1
`-- typescript@6.0.3

Cien paquetes. Las dependencias de las dependencias se llaman transitivas. Pidieron seis, y en su repositorio hay ahora 1399 líneas de lockfile y unos 50 MB en node_modules. El reporte de lockfile-report.ts, que lee solo el lockfile, dice de dónde salieron:

$ make reporte
Paquetes instalados: 100
Directos y lo que traen por sus dependencies:
  @eslint/js@10.0.1: 0
  @types/node@26.6.6: 1
  eslint@10.14.0: 76
  fast-check@4.10.2: 1
  typescript@6.0.3: 0
  typescript-eslint@8.72.1: 26
Con más de una versión en el árbol:
  eslint-visitor-keys: 3.4.3, 5.0.1
  hookified: 1.15.1, 2.2.0
  ignore: 5.3.2, 7.0.14
Licencias:
   74 MIT
   13 Apache-2.0
    6 BSD-2-Clause
    5 ISC
    1 BlueOak-1.0.0
    1 BSD-3-Clause
Con script de instalación: 0
Sin hash: 0
Descargados de: registry.npmjs.org

Casi todo viene con ESLint. TypeScript, en cambio, trae cero. Los números de la columna no suman 100 porque se repiten: un paquete puede venir por varios caminos. Y ese número puede no ser el que vean ustedes en el laboratorio: las directas están fijas, pero las transitivas se resuelven el día que se instala.

Para saber por qué está un paquete que no pidieron, existe npm explain:

$ npm explain minimatch | head -5
minimatch@10.2.6 dev
node_modules/minimatch
  minimatch@"^10.2.4" from @eslint/config-array@0.23.5
  node_modules/@eslint/config-array
    @eslint/config-array@"^0.23.5" from eslint@10.14.0

Cox recuerda por qué conviene mirar: en marzo de 2016, cuando el autor de un paquete de ocho líneas, left-pad, lo borró de npm, se rompieron los builds de la mayoría de los usuarios de Node. Proyectos como Babel, Ember y React dependían de él de forma indirecta, y muchos no lo sabían.

El problema del diamante

¿Qué pasa cuando dos paquetes piden versiones distintas de un tercero? SWEG lo llama el problema del diamante: su proyecto usa liba y libb, y las dos usan libbase. Si liba necesita la versión nueva de libbase y libb, la vieja, hay un problema: para SWEG, es el problema central de la gestión de dependencias.

A la izquierda, el diamante: su proyecto usa liba y libb; liba necesita libbase v2 y libb, libbase v1, y la pregunta es cuál. Si solo puede quedar una, una de las dos bibliotecas se queda sin la suya. A la derecha, arriba, npm cuando los rangos no se cruzan: eslint usa eslint-visitor-keys 5.0.1, en node_modules, y @eslint-community/eslint-utils, que eslint también usa, necesita la 3.4.3, anidada en su carpeta. Abajo, npm cuando los rangos se cruzan: @eslint/config-array pide minimatch ^10.2.4, typescript-estree pide ^10.2.2 y eslint pide ^10.2.6, y queda una sola copia, la 10.2.6, que cumple los tresA la izquierda, el diamante: su proyecto usa liba y libb; liba necesita libbase v2 y libb, libbase v1, y la pregunta es cuál. Si solo puede quedar una, una de las dos bibliotecas se queda sin la suya. A la derecha, arriba, npm cuando los rangos no se cruzan: eslint usa eslint-visitor-keys 5.0.1, en node_modules, y @eslint-community/eslint-utils, que eslint también usa, necesita la 3.4.3, anidada en su carpeta. Abajo, npm cuando los rangos se cruzan: @eslint/config-array pide minimatch ^10.2.4, typescript-estree pide ^10.2.2 y eslint pide ^10.2.6, y queda una sola copia, la 10.2.6, que cumple los tres
El problema del diamante y las dos salidas de npm: dos copias si los rangos no se cruzan, una sola si se cruzan. Dos copias no sirven cuando los paquetes tienen que compartir una.

Pero SWEG aclara que cada lenguaje lo tolera en distinto grado. C++ casi no lo tolera: tener dos versiones da comportamiento indefinido. Node sí, y mucho, por cómo busca los módulos: cada paquete mira primero en su propia carpeta node_modules. Así que npm tiene una salida que no todos tienen: instala las dos.

En nuestro árbol pasa con tres paquetes. eslint usa eslint-visitor-keys 5.0.1, pero @eslint-community/eslint-utils necesita la 3.4.3, y npm se la deja anidada:

node_modules/eslint-visitor-keys                                               5.0.1
node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys   3.4.3

Cuando los rangos sí se cruzan, deja una sola copia. minimatch se pide con tres rangos y hay una sola, la 10.2.6, que los cumple todos (npm ls la marca deduped).

Tener dos copias no siempre es inofensivo: son dos estados separados y dos versiones de los tipos, y hay que parchar las dos. Pero funciona. Entonces, ¿cuándo aparece el diamante de verdad en npm? Cuando dos copias no sirven, porque los paquetes tienen que compartir una sola.

peerDependencies

Un plugin de ESLint no puede traer su propio ESLint: tiene que enchufarse en el que está corriendo. Para eso están las peerDependencies: un paquete no trae esa dependencia, sino que declara con qué versión espera convivir. Y aquí sí vuelve la restricción de una sola versión:

$ npm view typescript-eslint@8.72.1 peerDependencies
{
  eslint: '^8.57.0 || ^9.0.0 || ^10.0.0',
  typescript: '>=4.8.4 <6.1.0'
}

Esta es la razón por la que el curso sigue en TypeScript 6, aunque ya exista la 7. npm outdated lo muestra:

$ npm outdated
Package     Current  Wanted  Latest  Location                 Depended by
typescript    6.0.3   6.0.3   7.0.2  node_modules/typescript  auditar
[código de salida: 1]

Wanted es lo máximo que acepta el rango de package.json, que es exacto. Latest es lo último publicado. Fíjense en que npm outdated sale con código 1 cuando encuentra algo, así que tampoco va así en un pipeline.

¿Qué pasa si igual actualizamos? Vale la pena verlo, porque el resultado no es el que se espera (make peer). Con el pedido explícito de la versión, npm solo avisa:

$ npm install --no-audit --no-fund --save-dev --save-exact typescript@7.0.2 2>&1 | cut -c1-90 | grep -v 'overriding peer' | sed -n 1,12p
npm warn While resolving: kallpa@1.0.0
npm warn Found: typescript@6.0.3
npm warn node_modules/typescript
npm warn   peer typescript@">=4.8.4 <6.1.0" from @typescript-eslint/eslint-plugin@8.72.1
...
npm warn Could not resolve dependency:
npm warn peer typescript@">=4.8.4 <6.1.0" from typescript-eslint@8.72.1
...

La instalación termina con código 0. tsc pasa. Parecería que salió bien. Pero el siguiente paso de make check no:

$ env PATH="$PWD/node_modules/.bin:/usr/local/bin:/usr/bin:/bin" make typecheck
tsc -p .
[código de salida: 0]

$ env PATH="$PWD/node_modules/.bin:/usr/local/bin:/usr/bin:/bin" make lint 2>&1 | head -3 | cut -c1-90
eslint .
typescript-eslint does not support TS 7.0.
Please see https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-si

Y en el pipeline ni siquiera llegaría ahí, porque npm ci es más estricto que npm install:

$ rm -rf node_modules && npm ci 2>&1 | cut -c1-90 | sed -n 1,5p
npm error code ERESOLVE
npm error ERESOLVE could not resolve
npm error
npm error While resolving: typescript-eslint@8.72.1
npm error Found: typescript@7.0.2

Esta es una buena ilustración del camino completo: la declaración de typescript-eslint decía la verdad, npm install permitió pasarla por alto con una advertencia que es fácil no leer, y npm ci y make check la hicieron cumplir. Sin el pipeline, alguien habría subido eso.

Con IA. “Sube TypeScript a la última versión” parece una tarea ideal para un agente, y un agente puede resolver el error de arriba con --legacy-peer-deps o --force, que apagan el reclamo sin arreglar nada. Son el equivalente al as any de la semana 11. Cuando vean una de esas opciones en un pull request o en un workflow, la pregunta es qué compatibilidad se declaró rota, y quién comprobó que no importa.

Antes de agregar una dependencia

Inspeccionar

Cox hace una comparación que ayuda: no contratarían a un desarrollador del que nunca oyeron hablar sin pedir referencias. Un paquete es lo mismo. Su artículo propone revisar nueve cosas, y aquí están con la manera de hacerlo en npm:

Pregunta Qué mirar Cómo
¿Existe y es el que creo? El nombre exacto y su repositorio npm view <p> name repository.url
Diseño ¿La documentación es clara? ¿Promete algo sobre la compatibilidad? El README
Calidad del código ¿Querrían depurar esto? Lean un poco El repositorio
Pruebas ¿Tiene? ¿Pasan? El repositorio y su CI
Errores ¿Hay reportes viejos sin respuesta? ¿Se arreglan? Sus issues
Mantenimiento ¿Quién? ¿Cuántas personas? ¿Último release? npm view <p> maintainers time
Uso ¿Lo usa más gente? Eso ayuda, pero no garantiza Las descargas en npmjs.com
Seguridad ¿Procesará entradas no confiables? ¿Tiene historial? La NVD y npm audit
Licencia ¿Tiene una? ¿Es aceptable? npm view <p> license
Dependencias ¿Cuántas trae? Cada una es otra inspección npm view <p> dependencies

La primera fila no es de Cox: la agregamos por lo que verán en la sección de paquetes inventados. El resto son sus categorías. Y Cox admite algo que hay que decir en voz alta: él mismo solo hace parte de esto, y para parte de sus dependencias. Nadie revisa a fondo cien paquetes. Lo razonable es ajustar el esfuerzo al riesgo: mucho para lo que llega a producción y procesa datos de terceros; poco para una herramienta de desarrollo muy conocida.

Dos ejemplos reales, con el comando del laboratorio (make paquetes):

$ npm view fast-check name version license repository.url time.modified dependencies
name = 'fast-check'
version = '4.10.2'
license = 'MIT'
repository.url = 'git+https://github.com/dubzzz/fast-check.git'
time.modified = '2026-09-19T19:34:55.280Z'
dependencies = { 'pure-rand': '^8.0.0' }
[código de salida: 0]

$ npm view request name version license repository.url time.modified deprecated
name = 'request'
version = '2.88.2'
license = 'Apache-2.0'
repository.url = 'git+https://github.com/request/request.git'
time.modified = '2026-07-17T17:10:13.431Z'
deprecated = 'request has been deprecated, see https://github.com/request/request/issues/3142'
[código de salida: 0]

fast-check tiene licencia conocida, repositorio, actividad reciente y una sola dependencia. request fue durante años uno de los clientes HTTP más usados de Node: existe y se puede instalar, pero el propio registro avisa que está deprecado. Además, hoy Node trae fetch. Un detalle del comando: time.modified dice cuándo cambiaron los datos del paquete en el registro, no cuándo salió el último release. La fecha de request es de 2026, y su versión 2.88.2 es de febrero de 2020. Para las fechas de cada versión, usen `npm view

time`. "Existe" y "es popular" son el primer paso de la revisión, no el último. Y la licencia también cuenta: Cox menciona que Google no permite código con licencias como la AGPL (demasiado exigente) o la WTFPL (demasiado vaga).

Una pregunta va antes que todas: ¿la puedo reemplazar con unas pocas líneas propias o con la biblioteca estándar? La comunidad de Go tiene un proverbio que Cox cita: a little copying is better than a little dependency. Node hoy trae HTTP, SQLite, un runner de pruebas, fetch, crypto.randomUUID, Intl y util.parseArgs. Muchas dependencias de hace cinco años ya no hacen falta.

Si la aceptan

Cox sigue más allá de la inspección, porque una dependencia no termina cuando se instala:

  • Pruébenla. Escriban pruebas de lo que necesitan de ella, y úsenlas para cada actualización. Su equipo descubrió así que una biblioteca de expresiones regulares era 70 veces más lenta que la más rápida en un caso básico, y empezó a replantear su uso.
  • Abstráiganla. Una interfaz propia y un wrapper delgado, con solo lo que usan. Si mañana hay que cambiarla, se cambia en un lugar. Es el ocultamiento de información de la semana 5.
  • Aíslenla, si procesa algo peligroso, en un proceso o un sandbox aparte.
  • Evítenla, si ninguna de las anteriores alcanza.
  • Actualícenla. Hacer diez actualizaciones chicas es menos trabajo que una grande. Cox cuenta el caso de Equifax en 2017: la vulnerabilidad de Apache Struts se publicó el 7 de marzo con su corrección, y los atacantes entraron el 13 de mayo y se llevaron datos de 148 millones de personas. Sus revisiones no encontraron esos servidores. Si tienen pruebas propias de la dependencia, dice Cox, en la mayoría de los casos esperar para actualizar es más riesgoso que actualizar rápido.
  • Vigílenla. Que siga siendo la misma versión; que no entren dependencias nuevas sin que se note; que siga con mantenimiento.

Dejarlo escrito

Agregar una dependencia es una decisión con consecuencias largas, justo lo que vieron en la semana 7 como una decisión de arquitectura. En el proyecto, cada dependencia nueva de producción se justifica en un ADR, y P4 pide además docs/dependencias.md con sus criterios, cómo fijan las versiones y qué hacen con las auditorías.

Un ADR de este tipo no necesita más que esto:

# ADR 7: usar la biblioteca X para generar PDF

**Contexto.** El cliente necesita recibos en PDF. Node no lo trae, y escribirlo nos tomaría semanas.
**Decisión.** Usar X 3.2.1, exacta.
**Revisado.** Licencia MIT. 2 mantenedores, último release hace un mes. Trae 4 paquetes.
Corre solo en el servidor y recibe solo datos nuestros, sin entrada del usuario.
**Cómo salir.** Se usa solo detrás de `ReceiptRenderer`.
**Consecuencias.** Revisar sus releases una vez por sprint.

La cadena de suministro

Su cadena de suministro es todo lo que interviene para que su software llegue a producción: paquetes, herramientas, actions, imágenes base, el registro, las cuentas de quienes publican. Un ataque a la cadena de suministro no busca un error en su código: busca meterse en algo en lo que ustedes ya confían. Estas son las formas más conocidas.

Typosquatting. Un paquete con un nombre casi igual a uno popular, publicado a la espera de que alguien se equivoque al escribir.

Dependency confusion. Una empresa usa un paquete interno, acme-utils, de su registro privado. Alguien publica en el registro público un paquete con ese mismo nombre y una versión más alta. Si la configuración busca en los dos, puede bajar el público. Es lo que mostró Alex Birsan en 2021, con paquetes de prueba. Los scopes de npm (@acme/utils) y una configuración que ligue cada scope a su registro son la defensa.

Un mantenedor comprometido o malicioso. Es el más difícil, porque el paquete es el correcto. El caso de referencia es xz utils.

En la industria. xz es una biblioteca de compresión presente en casi cualquier Linux. Según la cronología de Russ Cox, una persona que se presentaba como Jia Tan mandó su primer parche, inofensivo, el 29 de octubre de 2021. Durante más de dos años aportó parches, mientras otras cuentas presionaban al mantenedor principal para que sumara ayuda, y llegó a ser comantenedor. El 24 de febrero de 2024 publicó la versión 5.6.0 con la puerta trasera (CVE-2024-3094). Un detalle técnico clave: el archivo que la activaba estaba en el tarball publicado, no en el repositorio de código. Quien leyera Git no lo veía. La descubrió Andres Freund, en marzo de 2024, cuando ya estaba en Fedora Rawhide y en la beta de Fedora 40, y a punto de entrar a más distribuciones.

Fíjense en que ninguna de las defensas de arriba lo habría detenido. Un lockfile habría fijado con exactitud el paquete malicioso.

En la industria. En septiembre de 2025 hubo dos episodios seguidos en npm. GitHub marcó como malware la versión 4.4.2 de debug, y días después CISA alertó sobre un gusano que comprometió más de 500 paquetes y buscaba credenciales. Hoy, debug está en el árbol de ESLint que instalarán, en la versión 4.4.3, pedida con ^4.3.1. Un rango así acepta justo la 4.4.2 (semver-range.test.ts lo comprueba). Por eso, en un proyecto que pedía debug con un rango como ese, un npm install sin lockfile en esos días podía bajarla, y un npm ci con un lockfile anterior, no. Esa versión ya no se puede descargar:

$ npm view debug@4.4.2 version 2>&1 | head -2
npm error code E404
npm error 404 No match found for version 4.4.2

Defensas, y lo que no cubren

Ninguna defensa alcanza por sí sola. Lo útil es saber qué cubre cada una:

Defensa Qué garantiza Qué no garantiza
Lockfile con hashes Instalan lo mismo que la primera vez Que la primera vez fuera bueno
npm ci El pipeline no cambia el árbol por su cuenta Que lo que dice el lockfile sea bueno
npm audit Reporta vulnerabilidades ya publicadas Lo que todavía nadie reportó
npm audit signatures Lo que bajó lo firmó el registro; si tiene atestación, de dónde se construyó Que el código sea bueno
Fijar actions al SHA La action no cambia sin que lo sepan Que ese commit sea bueno
SBOM Saben qué tienen, para responder “¿nos afecta?” Nada, por sí solo: es una lista
SLSA Un lenguaje común, con niveles, sobre cómo se construyó algo Que el código fuente sea bueno
OpenSSF Scorecard Una revisión rápida de las prácticas de un proyecto Que el paquete sea seguro: mide hábitos
Esperar antes de actualizar Lo recién publicado no entra de inmediato Protege poco contra ataques largos, como xz
No correr scripts de instalación Instalar no ejecuta código El código corre igual cuando se usa
Revisar cada paquete nuevo Alguien miró Lo que no se puede ver leyendo
Cinco tramos encadenados, cada uno con su ataque y su defensa. Quien publica: mantenedor comprometido o malicioso, como xz, con más de dos años de parches; esperar antes de actualizar ayuda poco contra algo así. Lo publicado: el tarball no coincide con el repositorio, y en xz quien leyera Git no lo veía; la defensa es la atestación de procedencia, si el build es confiable. El nombre: typosquatting, dependency confusion y paquetes inventados; la defensa es npm view antes de agregar y scopes ligados a su registro. La instalación: un rango acepta una versión mala, como debug 4.4.2, o un script corre al instalar; la defensa es lockfile y npm ci, e --ignore-scripts. El pipeline: una etiqueta de action se mueve, como tj-actions/changed-files; la defensa es fijar al SHA y permissions: contents: read. Abajo: un lockfile garantiza que instalan lo mismo que la primera vez, no que la primera vez fuera bueno, y habría fijado con exactitud el paquete malicioso de xzCinco tramos encadenados, cada uno con su ataque y su defensa. Quien publica: mantenedor comprometido o malicioso, como xz, con más de dos años de parches; esperar antes de actualizar ayuda poco contra algo así. Lo publicado: el tarball no coincide con el repositorio, y en xz quien leyera Git no lo veía; la defensa es la atestación de procedencia, si el build es confiable. El nombre: typosquatting, dependency confusion y paquetes inventados; la defensa es npm view antes de agregar y scopes ligados a su registro. La instalación: un rango acepta una versión mala, como debug 4.4.2, o un script corre al instalar; la defensa es lockfile y npm ci, e --ignore-scripts. El pipeline: una etiqueta de action se mueve, como tj-actions/changed-files; la defensa es fijar al SHA y permissions: contents: read. Abajo: un lockfile garantiza que instalan lo mismo que la primera vez, no que la primera vez fuera bueno, y habría fijado con exactitud el paquete malicioso de xz
Cada tramo de la cadena se ataca de otra forma y se defiende de otra forma. Ninguna defensa cubre todos los tramos.

Veamos algunas con Kallpa.

Firmas. npm audit signatures (make auditar):

$ npm audit signatures
audited 100 packages in 2s

100 packages have verified registry signatures

33 packages have verified attestations
(use --json --include-attestations to view attestation details)

[código de salida: 0]

Las firmas del registro dicen que el archivo que bajaron es el que el registro de npm publicó. Las atestaciones de procedencia dicen algo más fuerte: de qué repositorio y qué commit se construyó el paquete, con qué sistema de build. Solo 33 de los 100 las tienen. Eso no significa que los otros 67 estén mal: significa que de esos no sabemos tanto. Observen el caso xz: lo publicado no coincidía con el repositorio. La procedencia ataca justo ese agujero, siempre que el build sea confiable. Ninguna de las dos dice nada sobre si el código hace lo que debe.

SBOM. Un software bill of materials es la lista de todo lo que va en un producto. npm la genera:

$ npm sbom --sbom-format cyclonedx | jq -r '.bomFormat, .specVersion, (.components | length)'
CycloneDX
1.5
100

$ npm sbom --sbom-format cyclonedx --omit=dev | jq '.components | length'
0

$ npm sbom --sbom-format cyclonedx | jq -c '.components[] | select(.name == "minimatch") | {name, version, purl, licenses}'
{"name":"minimatch","version":"10.2.6","purl":"pkg:npm/minimatch@10.2.6","licenses":[{"license":{"id":"BlueOak-1.0.0"}}]}

Lo que llega a producción de Kallpa son cero paquetes. Y si mañana sale una alerta sobre un paquete cualquiera, con la lista se responde en segundos. Cox contaba que los escaneos de Equifax no encontraron unos servidores: se puede arreglar lo que se sabe que se tiene.

Scripts de instalación. Un paquete de npm puede declarar scripts que corren al instalarlo, con los permisos de quien instala, antes de que nadie use el código. Es el camino favorito de varios ataques. En nuestro árbol, ninguno los usa (Con script de instalación: 0, y lo comprueba lockfile-report.test.ts). La opción --ignore-scripts los apaga, y las versiones recientes de npm tienen además una política, allowScripts en package.json, para aprobarlos paquete por paquete.

Esperar. npm tiene una opción min-release-age, en días, para instalar solo versiones que ya llevan un tiempo publicadas. La lógica: darle tiempo a alguien para que detecte y retire una versión mala, como pasó con debug. El costo es que también retrasa las correcciones de seguridad, y no alcanza contra algo como xz, que estuvo más de un mes publicado sin que nadie lo notara.

SLSA y Scorecard. SLSA (Supply-chain Levels for Software Artifacts) es un marco de requisitos, en niveles de garantía crecientes, para asegurar que un artefacto salió de donde dice que salió y no se alteró en el camino. Sirve más a quien publica software. Scorecard, de OpenSSF, revisa automáticamente las prácticas de riesgo de un proyecto de código abierto: si fija sus dependencias, si protege sus ramas, si tiene revisión de código. Ambas sirven para hacer preguntas. Ninguna dice “es seguro”.

Paquetes que no existen

El ataque

Hay una forma nueva de la misma idea. Un modelo de lenguaje sugiere npm install de un paquete con un nombre razonable, y ese paquete no existe. Alguien registra ese nombre, con código malicioso, y espera a que otra persona reciba la misma sugerencia. Se le suele llamar slopsquatting. El término circula en la industria; no viene del artículo que vamos a leer, que habla de package hallucinations.

Lo que midió el estudio

Spracklen y otros (USENIX Security 2025) hicieron la medición de referencia. Conviene saber con precisión qué midieron.

  • Qué hicieron. Generaron 576 000 muestras de código con 16 modelos, en Python y JavaScript, a partir de dos conjuntos de prompts: preguntas de Stack Overflow y descripciones de paquetes populares. De cada muestra extrajeron los nombres de paquetes y los compararon con las listas de PyPI y npm del 10 de enero de 2024.
  • Qué encontraron. De 2.23 millones de nombres generados, 440 445 (19.7 %) no existían, y entre ellos había 205 474 nombres distintos. El promedio fue de al menos 5.2 % en modelos comerciales y 21.7 % en modelos abiertos. En JavaScript hubo más que en Python (21.3 % y 15.8 %).
  • ¿Se repiten? Esta es la cifra que hace viable el ataque. Volvieron a correr 500 prompts que habían producido un nombre inventado, 10 veces cada uno: el 43 % de esos nombres salió en las 10 veces, el 39 % no volvió a salir, y el 58 % se repitió más de una vez. La mayoría no son errores al azar. Basta con que un nombre se repita para que valga la pena registrarlo.

Y sus límites, que importan tanto como los números:

  • Los porcentajes son de los nombres de paquetes generados, no de los programas ni de las veces que un desarrollador usa la sugerencia.
  • Son modelos de 2023–2024, y los comerciales evaluados son tres de la serie GPT. Los modelos cambian rápido: estas cifras no describen lo que hace hoy una herramienta concreta. El propio artículo menciona que un trabajo previo dio tasas 4 a 6 veces más altas para los mismos modelos GPT, así que el número también depende del método.
  • Los prompts están pensados para producir código que use paquetes. No miden con qué frecuencia una persona se encuentra con uno de estos nombres mientras trabaja.
  • El 81 % de los nombres inventados salió de un solo modelo. Es decir, un atacante tendría que apuntar a un modelo en particular.
  • Mide el riesgo, no los ataques. El estudio no muestra cuántas personas fueron afectadas.

Idea clave. Lo sólido del estudio es el mecanismo: los modelos inventan nombres de paquetes, y muchos de esos nombres se repiten. Lo que no se puede sacar de ahí es “la herramienta que uso inventa un 5 %”.

La defensa

La defensa es la de siempre, sin excepciones: no se instala nada que nadie haya verificado. Se empieza por ver si existe:

$ npm view kallpa-slots-helper name version license repository.url time.modified | cut -c1-90
npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/kallpa-slots-helper - Not found
npm error 404
npm error 404  The requested resource 'kallpa-slots-helper@*' could not be found or you do not have permission to access it.
npm error 404
npm error 404 Note that you can also install from a
npm error 404 tarball, folder, http url, or git url.
...

Ese nombre lo acabamos de inventar nosotros, para este ejemplo. Y esa es la parte fácil. Un 404 es el mejor resultado posible. El peor es que el comando responda, porque alguien ya registró ese nombre. Como dice el artículo, comparar el nombre con una lista de paquetes conocidos no sirve si el atacante ya publicó el paquete. Existir en el registro no es evidencia de nada. Por eso la revisión sigue:

  1. ¿Existe? npm view.
  2. ¿Es el que dice ser? ¿El repositorio existe y coincide? ¿La documentación del proyecto oficial lo nombra? ¿Lo llegaron a buscar ustedes, o solo vino de la sugerencia?
  3. ¿Cuándo nació? Un paquete creado hace dos semanas y con un solo release merece desconfianza (npm view <p> time.created).
  4. ¿Quién lo mantiene? ¿Cuántas personas? ¿Qué más publicaron?
  5. ¿Qué trae? ¿Tiene scripts de instalación? npm view <p> scripts dependencies.
  6. ¿Hace falta? La pregunta de la sección anterior. La mayoría de las veces, esta es la que resuelve.

Solo después, y nunca en una máquina con credenciales a la mano si hay dudas. Instalar ya ejecuta código.

Con IA. Cuando trabajen con un agente, tres reglas concretas. Primero, un agente no instala paquetes sin que alguien lo apruebe: npm install es una acción que corre código de terceros en su máquina. Segundo, cualquier cambio en package.json o en package-lock.json en un pull request se revisa con el comando de arriba, paquete por paquete. La semana 11 ya lo dejó en la lista de cosas que mirar primero. Tercero, si un paquete no existe, no le pidan al mismo agente “uno parecido” y lo instalen: vuelvan a la pregunta de si hace falta. El registro de uso de IA debe decir qué se sugirió, qué se comprobó y qué se decidió.

Leer npm audit con criterio

Qué dice el reporte

npm audit compara su árbol con una base de vulnerabilidades publicadas. Esta es la corrida del laboratorio, sin las líneas de rutas (make auditar):

$ npm audit | grep -v -E 'Depends on|node_modules/'
# npm audit report

minimatch  <11.0.1
Severity: moderate
minimatch has potential denial of service via globparts amplification - https://github.com/advisories/GHSA-g3hv-75wg-m249
fix available via `npm audit fix --force`
Will install eslint@4.0.0, which is a breaking change
  @eslint/config-array  *
    eslint  0.7.1 - 2.0.0-rc.1 || >=4.1.0
      @eslint-community/eslint-utils  *
        @typescript-eslint/utils  *
          @typescript-eslint/eslint-plugin  *
            typescript-eslint  *
          @typescript-eslint/type-utils  >=5.9.2-alpha.0
      @eslint/js  >=10.0.1
      @typescript-eslint/parser  *
  @typescript-eslint/typescript-estree  >=6.16.0

11 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Primera lectura: “11 vulnerabilidades moderadas”. Lectura correcta: una sola vulnerabilidad, en minimatch, y 11 paquetes marcados porque están en el camino hacia ella (minimatch y otros 10 que dependen de él, directa o indirectamente). npm audit cuenta paquetes afectados, no problemas distintos. Y --json lo deja claro: 3 de esos 11 son directos, y la causa aparece solo en uno.

Cinco preguntas

Para decidir qué hacer, se responde en este orden.

1. ¿Llega a producción?

$ npm audit --omit=dev
found 0 vulnerabilities
[código de salida: 0]

No. Son todas dependencias de desarrollo. Kallpa no tiene paquetes en producción, así que un vecino que reserva una cancha nunca ejecuta ese código.

2. ¿Qué tiene que hacer un atacante? Leamos el aviso. Dice: an attacker who can supply a glob pattern puede causar un consumo excesivo de CPU y memoria. Para aprovecharla en este proyecto, alguien tendría que controlar los patrones de archivos que recibe ESLint, y esos están en su propia configuración. Quien puede cambiar esa configuración ya puede correr lo que quiera en el pipeline. El peor resultado sería un eslint . lento.

3. ¿Hay arreglo? La corrección está en minimatch 11.0.1, publicada el 27 de octubre de 2026. Pero ESLint 10.14.0 pide ^10.2.6, y ese rango no acepta la 11. No podemos arreglarlo nosotros sin forzar la dependencia de otro: hay que esperar un release de ESLint.

4. ¿Qué propone la herramienta, y qué hace? Propone npm audit fix --force. Veamos qué pasa.

5. ¿Qué decidimos, y dónde queda escrito? Lo vemos después del experimento.

npm audit fix --force

El nombre es tentador: un comando y el reporte queda en cero. Esto es lo que hace aquí (make fuerza, en una copia):

$ npm audit fix --force --dry-run 2>&1 | grep -E 'Updating'
npm warn audit Updating eslint to 4.0.0, which is a SemVer major change.
npm warn audit Updating @eslint/js to 9.39.5, which is a SemVer major change.
npm warn audit Updating typescript-eslint to 8.14.0, which is a SemVer major change.

ESLint 4.0.0 se publicó en junio de 2017. Para arreglar una vulnerabilidad moderada en una herramienta de desarrollo, propone retroceder seis versiones mayores. Si seguimos adelante:

$ npm audit fix --force --no-fund > /dev/null 2>&1
[código de salida: 1]

$ npm pkg get devDependencies
{
  "@eslint/js": "^9.39.5",
  "@types/node": "26.6.6",
  "eslint": "^4.0.0",
  "fast-check": "4.10.2",
  "typescript": "6.0.3",
  "typescript-eslint": "^8.14.0"
}
[código de salida: 0]

$ npm audit 2>&1 | tail -7 | head -1
17 vulnerabilities (2 low, 14 moderate, 1 high)

$ env PATH="$PWD/node_modules/.bin:/usr/local/bin:/usr/bin:/bin" make check
tsc -p .
eslint .

Oops! Something went wrong! :(

ESLint couldn't find a configuration file. To set up a configuration file for this project, please run:

    eslint --init
...
make: *** [Makefile:31: lint] Error 1
[código de salida: 2]

Fíjense en el resultado. El reporte no quedó en cero. Quedó peor: de 11 moderadas a 17, ahora con una alta. Las versiones exactas se convirtieron en rangos con ^. ESLint ya no sabe leer la configuración del proyecto, y el build se rompió. Si alguien lo hubiera hecho en una rama sin mirar y con un pipeline apagado, lo habría subido.

Error común. Correr npm audit fix --force para que el reporte quede en cero. --force significa exactamente lo que dice: hace el cambio aunque sea de MAJOR. Sin --force, npm audit fix solo aplica actualizaciones que los rangos permiten, y eso sí se puede probar con tranquilidad: revisen el diff del lockfile y corran make check.

La decisión se escribe

Después del análisis, la decisión es una línea de trabajo, no un silencio. En docs/dependencias.md, o en la descripción del pull request:

**GHSA-g3hv-75wg-m249, minimatch < 11.0.1 (moderada), visto el 2 de noviembre de 2026.**
Llega por eslint y typescript-eslint. Solo en desarrollo: `npm audit --omit=dev` encuentra 0.
Para aprovecharla hay que controlar los patrones de ESLint, que son los de nuestra configuración.
Se acepta el riesgo. No se usa `--force`: instala ESLint 4 y rompe `make check`.
Se vuelve a mirar con cada release de ESLint.

¿Y si estuviera en una dependencia que llega a producción? Cambia la primera respuesta, y casi seguro la decisión. Si el servidor le pasa a esa función texto que manda un usuario, un atacante sí puede dar el patrón, y lo que era “un eslint . lento” pasa a ser una caída del servicio. Se busca una versión corregida, se evalúa cambiar de paquete, se aísla o se le pone un límite al tamaño de la entrada, y se hace ya. P4 lo pide así: sin vulnerabilidades altas ni críticas en las dependencias de producción.

En el pipeline

Por omisión, npm audit sale con código distinto de cero por cualquier hallazgo. La documentación recomienda para CI la opción --audit-level, que no cambia el reporte, sino el umbral del fallo:

$ npm audit --audit-level=high > /dev/null
[código de salida: 0]

$ npm audit --audit-level=moderate > /dev/null
[código de salida: 1]

Si ponen npm audit sin nivel en el pipeline, queda en rojo desde el primer día. Ya vieron qué le pasa a un equipo con un rojo permanente.

Hay una consideración más fina, que conecta con el inicio del capítulo: npm audit no es hermético. Mira la base de vulnerabilidades de hoy. Un commit que estaba en verde ayer puede salir en rojo hoy, sin que nadie haya cambiado nada, porque alguien publicó un aviso. Ese rojo es información útil, pero bloquea un pull request que no tiene la culpa. Por eso muchos equipos separan esa revisión del check obligatorio: un job aparte, o uno que corre una vez al día. En el laboratorio van a elegir un nivel, y conviene que su justificación diga qué pasa el día que aparezca un aviso nuevo.

Idea clave. Un reporte de vulnerabilidades es el inicio de un análisis. El número de arriba no mide cuánto riesgo tienen: para eso hay que preguntar si llega a producción, qué tiene que hacer un atacante y si hay arreglo.

El laboratorio: un pipeline para Kallpa

El laboratorio se hace sobre su repositorio de Kallpa, en una rama pipeline-ci. Los comandos de npm y make van en la shell del contenedor; git push y la configuración de GitHub, en la terminal de su computadora. Los pasos tienen este sentido:

  1. Declarar las herramientas. Las seis directas con --save-exact. Busquen en package-lock.json un paquete que no hayan instalado y pregunten con npm explain quién lo pidió. Hagan commit de los dos archivos. Si npm install les da otro número de paquetes, es normal: anótenlo.

  2. Simular el runner, con npm ci y los dos PATH de arriba.

  3. Escribir el workflow en .github/workflows/ci.yml. Partan del primer intento de este capítulo y agreguen lo que falta. Pueden revisarlo antes de subirlo:

    node /work/code/week-12/revisar-workflow.ts .github/workflows/ci.yml
  4. Subir la rama y abrir el pull request. Vean el check en la pestaña Checks y abran el log de cada step. Anoten cuánto tardó el job, y cuánto de eso fue npm ci. Este apunte no les da esa cifra, porque depende de GitHub y de ese día.

  5. Romperlo a propósito, una vez con tsc y otra con una prueba. Encuentren la línea exacta del error en el log y cierren el pull request sin hacer merge. Si el check no se pone rojo, encontraron un problema en su workflow.

  6. Proteger main. Exijan pull request con aprobación y el check. Comprueben que el botón queda bloqueado. Antes, revisen si su repositorio es público o si tienen GitHub Pro con GitHub Education.

  7. Dependencias con criterio. npm audit con sus variantes, npm outdated y npm audit signatures. En la descripción del pull request expliquen qué dice cada uno, por qué no usan --force y qué nivel eligieron para su step.

  8. Paquetes que no existen. code/week-12/sugerencia.md es una ilustración escrita para la clase: un cambio con tres npm install. Antes de instalar nada, revisen cada nombre y escriban la decisión como en su registro de uso de IA.

Lo que hagan hoy es el primer paso de P4, que pide lo mismo para su proyecto, con un npm run check, un job de deploy y docs/dependencias.md.

En la industria. Los equipos no suelen revisar dependencias a mano todos los días. Herramientas como Dependabot abren un pull request por cada actualización, y ahí se ve el valor de todo este capítulo: si el pipeline es confiable y las pruebas son buenas, un pull request de actualización en verde se puede revisar en minutos. SWEG lo dice en una frase: las pruebas y CI son lo que da evidencia real de que un conjunto de versiones funciona junto. Sin eso, actualizar es un salto al vacío, y por eso se posterga hasta que una vulnerabilidad obliga a hacerlo de golpe.

El código de la semana y make test

Todo está en code/week-12:

Archivo Qué es
semver-range.ts, semver-range.test.ts Qué acepta ^, ~ o una versión exacta, comparado con el paquete semver
lockfile-report.ts, lockfile-report.test.ts El reporte del lockfile: paquetes, versiones anidadas, licencias, scripts
esperado/ package.json y package-lock.json de la corrida del 2 de noviembre de 2026
revisar-workflow.ts, revisar-workflow.test.ts Revisa un workflow contra las reglas de la semana
ejemplos/workflow-incompleto.yml El primer intento
ejemplos/workflow-peligroso.yml Todo lo que no hay que hacer
ejemplos/fecha-local.ts, fecha-local.check.ts La prueba que pasa en Lima y falla en UTC
flaky.ts, flaky.test.ts La cuenta de las pruebas inestables
escenarios.sh, escenarios.test.ts make tipos, make colision y make zona
registro.sh Las corridas que usan el registro de npm
runner-limpio.sh make check en una imagen de Node sin las herramientas, desde su computadora
sugerencia.md, sugerencia.test.ts El material del paso 8

make test revisa los tipos, corre ESLint y las pruebas, y no usa la red, a propósito: si dependiera del registro de npm, sería un presubmit que puede fallar porque se cayó internet, justo lo que este capítulo critica.

$ cd code/week-12
$ make test
tsc -p .
eslint .
node --test "*.test.ts"
...
ℹ tests 44
ℹ suites 0
ℹ pass 44
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
...

Lo que depende del registro está en objetivos aparte, que imprimen la salida de hoy y dejan una copia en build/salidas/: make arbol, make runner, make falta, make lockfile, make auditar, make fuerza, make peer, make rangos, make sbom y make paquetes. Nada de esto modifica kallpa/, y make clean borra build/.

Resumen

  • La integración continua es una práctica: cada integrante integra a la rama principal al menos una vez al día, y cada integración se verifica con un build automático. Un pipeline perfecto con ramas de tres semanas no es integración continua. Dos ramas en verde pueden dar un merge en rojo, sin conflictos de Git.
  • Cuando Node ejecuta TypeScript sin compilar, el build es tsc, eslint y las pruebas: make check. Sin él, los tipos son comentarios.
  • Un build reproducible da lo mismo con las mismas entradas, y uno hermético no depende de lo que no declaró. El pipeline llama a lo mismo que corre una persona.
  • Presubmit lleva pruebas rápidas y confiables; post-submit, las demás. Una prueba flaky enseña a ignorar el rojo.
  • En GitHub Actions, un workflow se dispara con eventos y tiene jobs que corren en runners; cada job tiene steps, que son comandos o actions. Un step falla por su código de salida, y una tubería sin pipefail lo esconde.
  • Seguridad: permissions: contents: read, secrets fuera del código y de los logs, y actions fijadas, porque una etiqueta se puede mover. Sin protección de rama con required status checks, el pipeline es decorativo.
  • Semver es una promesa de quien publica; las pruebas son la evidencia. ^ acepta hasta el siguiente MAJOR, salvo en 0.x; ~, hasta el siguiente MINOR.
  • El lockfile fija la versión y el hash de cada paquete, también los transitivos. En el pipeline va npm ci. Garantiza que lo instalado es lo mismo, no que sea bueno.
  • Seis dependencias directas instalan 100 paquetes. npm tolera muchos diamantes con copias anidadas; con las peerDependencies, el diamante vuelve.
  • Antes de agregar una dependencia: ¿existe?, ¿quién la mantiene?, ¿qué trae?, ¿qué licencia?, ¿hace falta? Cada una de producción va con un ADR.
  • La cadena de suministro se ataca por typosquatting, dependency confusion o un mantenedor comprometido, como en xz. Ninguna defensa cubre todo.
  • Los modelos de lenguaje inventan nombres de paquetes, y muchos se repiten. Existir en el registro no prueba nada. No se instala nada que nadie haya verificado.
  • npm audit se lee preguntando si llega a producción, qué necesita el atacante y si hay arreglo, y la decisión se escribe. Aquí, npm audit fix --force pasó de 11 a 17 vulnerabilidades y rompió el build.

Ejercicios

  1. Un equipo tiene un pipeline que corre en cada push, pero cada integrante trabaja dos semanas en su rama antes de hacer merge. ¿Hace integración continua? Propongan dos cambios concretos para empezar, y digan qué medirían para saber si funcionaron.
  2. Sin correr nada, digan qué versiones acepta cada rango, y cuáles de 1.2.3, 1.3.0, 2.0.0, 0.2.5, 0.3.0 y 0.0.9 entran en cada uno: (a) ^1.2.3; (b) ~1.2.3; (c) ^0.2.3; (d) ~0.2.3; (e) ^0.0.9. Comprueben sus respuestas con satisfies de semver-range.ts.
  3. Hagan los siguientes cambios en una copia de Kallpa y digan, antes de correr, qué pasos de make check fallan y cuáles no: (a) pasar un string a addDays en un archivo de src; (b) cambiar PEAK_SURCHARGE_PERCENT a 30; (c) dejar una variable sin usar en src/domain/report.ts. ¿Cuáles de los tres no detectaría node --test?
  4. Una suite de 200 pruebas tiene 5 pruebas flaky, cada una con 2 % de falla. Calculen cuántos de 100 builds saldrían en rojo sin razón, con falseRedProbability. Si el equipo reintenta una vez cada build en rojo, ¿cuántos quedan? ¿Qué supuesto están usando, y qué se pierde con el reintento?
  5. En make colision, cambien el orden: primero hagan merge de linea-de-caja y después de renombra-soles. ¿Da conflicto? ¿Falla make check? Expliquen qué haría la protección de rama en modo estricto en cada caso, y qué comando correría cada persona.
  6. Lean ejemplos/workflow-peligroso.yml. Para cada línea marcada, escriban qué le puede pasar a un equipo real, y la línea corregida. Pasen el resultado por revisar-workflow.ts hasta que dé 0 errores. ¿Qué error no encontraría el revisor, aunque el workflow esté mal?
  7. Agreguen a revisar-workflow.ts una regla: un npm audit en un workflow que no use --omit=dev da un aviso. Escriban primero su prueba. Después, decidan si debería ser un error, y justifíquenlo con un ejemplo.
  8. Agreguen a lockfile-report.ts una lista de los paquetes con el mayor número de pedidos (usen requesters). ¿Cuáles son los cinco más pedidos en esperado/package-lock.json? ¿Qué pasaría si uno de ellos publica una versión maliciosa, y cómo les llegaría según tengan lockfile o no?
  9. Escojan un paquete de dependencies de su proyecto (o, si no tienen, fast-check). Háganle la inspección completa de Cox, con evidencia para cada una de sus nueve categorías: comandos, enlaces y lo que encontraron. Terminen con un ADR de la forma que propone este capítulo.
  10. Un aviso reporta denegación de servicio en una biblioteca de análisis de fechas, con severidad alta. Su proyecto la tiene como dependencia de producción. Escriban la decisión para dos casos: (a) solo procesa fechas generadas por su propio servidor; (b) procesa un campo de un formulario público. ¿Cambia la urgencia? ¿Qué harían mientras no haya versión corregida?
  11. Busquen en npm tres nombres que les haya sugerido una herramienta de IA en P2, P3 o en su registro de uso. Apliquen los seis pasos de “La defensa”. Si todos existen, ¿qué aprendieron sobre lo que npm view puede y no puede decirles?
  12. Propongan el pipeline de P4 para su proyecto: qué corre en presubmit y qué en post-submit, qué entradas fijan y cuáles no, y dónde va la auditoría. Escriban la lista de cosas que hoy podrían hacer que el pipeline se ponga rojo sin que nadie haya tocado el código.