12
Calidad: pruebas, revisión e integración

Integración continua y dependencias

Calidad

Semana 12 — Integración continua y dependencias

Objetivos

  • Definir integración continua (como práctica, no como herramienta) y explicar qué hace que un build sea confiable: reproducible, rápido, con el resultado a la vista de todos y arreglado apenas se rompe.
  • Construir un pipeline de GitHub Actions que revise cada pull request y que la protección de la rama main haga obligatorio.
  • Explicar semver, los rangos de versiones, el lockfile y la diferencia entre npm install y npm ci, y por qué una dependencia directa trae decenas de transitivas.
  • Evaluar una dependencia antes de agregarla y leer con criterio un reporte de npm audit.
  • Reconocer los ataques a la cadena de suministro, incluidos los paquetes inventados por un modelo de lenguaje (slopsquatting), y las defensas que existen.

Sesión A — Integración continua y build

Lecturas:

Lecturas complementarias:

Temas:

  • Integración continua es una práctica: 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. El servidor de CI solo la hace cumplible. Un equipo con un pipeline perfecto y ramas que viven tres semanas no hace integración continua.
  • Las reglas que la hacen funcionar: un solo repositorio y una rama principal, un build que se prueba a sí mismo (self-testing build), que corre en minutos, cuyo resultado ve todo el equipo, y que cuando se rompe se arregla antes que cualquier otra cosa (o se revierte el commit). Un build que pasa “casi siempre” no da información.
  • Qué es el build cuando Node ejecuta TypeScript sin compilar: no hay un paso que genere JavaScript, pero sí hay un build. Para Kallpa son tsc (tipos), eslint y las pruebas: exactamente lo que corre make check. En la semana 13 se agrega la imagen de contenedor. La regla: el pipeline llama a lo mismo que corre una persona en su computadora, no a una copia con otros comandos.
  • Build reproducible (SWEG cap. 18; el cap. 23 habla de pruebas herméticas): con las mismas entradas da el mismo resultado, sin depender de lo que esté instalado en la máquina. Por eso las versiones de las herramientas se fijan. “En mi computadora pasa” casi siempre significa que algo no estaba fijado: la versión de Node, de TypeScript, de una dependencia, la zona horaria.
  • En Google (SWEG cap. 23), presubmit son las pruebas que corren antes de integrar un cambio (rápidas, confiables) y post-submit las que corren después (más lentas o más grandes). En GitHub esto se traduce en un workflow que corre en pull_request y otro, o el mismo, en push a main. Una prueba flaky en presubmit es peor que no tenerla: enseña al equipo a ignorar el rojo.
  • Conceptos de GitHub Actions: un workflow (un archivo YAML en .github/workflows/) se dispara con eventos; tiene jobs que corren en un runner (una máquina virtual nueva cada vez) y cada job tiene steps, que son comandos (run:) o actions reutilizables (uses: actions/checkout@...). Los jobs corren en paralelo salvo que uno declare needs:.
  • Seguridad del pipeline: el GITHUB_TOKEN con los permisos mínimos (permissions: contents: read), los secrets nunca en el código ni en los logs, y las actions de terceros fijadas a una versión. Una etiqueta como v45 se puede mover: en marzo de 2025 alguien reescribió las etiquetas de tj-actions/changed-files para que apuntaran a un commit que imprimía los secrets en los logs (CVE-2025-30066). Fijar al SHA del commit evita eso.
  • Error común: un pipeline que no impide nada. Si main no está protegida y no se exige que el check pase, el pipeline es decorativo: el pull request se integra en rojo “porque era urgente”. La protección de rama con required status checks es lo que convierte el pipeline en una regla. Vale también para el código que escribe un agente: el pipeline atrapa lo que rompió sin darse cuenta, y por eso a un agente no se le da permiso para saltarse los checks ni para cambiar el workflow sin revisión.

Sesión B — Dependencias y cadena de suministro

Lecturas:

Lecturas complementarias:

Temas:

  • Una dependencia es código que no escribieron y del que igual responden: sus errores, sus vulnerabilidades y sus cambios son ahora de ustedes. Agregar una es fácil; mantenerla, años de trabajo. SWEG lo resume en que la gestión de dependencias es más difícil que el control de versiones porque el tiempo y la escala no se controlan.
  • Versionado semántico (MAJOR.MINOR.PATCH): MAJOR cambia cuando se rompe la compatibilidad, MINOR cuando se agrega algo compatible, PATCH cuando se corrige algo compatible. Es una promesa de quien publica, no una garantía: por la ley de Hyrum, un PATCH puede romper a quien dependía de un comportamiento que nadie prometió. Rangos en package.json: ^8.72.1 acepta cualquier 8.x.y desde 8.72.1 (en versiones 0.x, el rango es más estrecho: ^0.23.5 se detiene antes de 0.24.0); ~8.72.1, solo 8.72.x; 8.72.1, exactamente esa versión. El lockfile (package-lock.json) registra la versión exacta y el hash (integrity) de cada paquete instalado, también los transitivos. npm install puede actualizar el lockfile; npm ci instala exactamente lo que dice, borra node_modules antes y falla si package.json y el lockfile no coinciden. En el pipeline siempre va npm ci.
  • Dependencias transitivas: en el laboratorio, seis devDependencies directas (TypeScript, los tipos de Node, ESLint y sus reglas, typescript-eslint y fast-check) instalan 100 paquetes. El problema del diamante: dos de ellos piden versiones incompatibles de un tercero; npm suele resolverlo instalando dos copias, y el conflicto duro reaparece con las peerDependencies. Las peerDependencies declaran con qué versión de otro paquete se espera convivir: por eso el curso sigue en TypeScript 6 aunque ya exista la 7, porque typescript-eslint todavía no la soporta, y npm outdated lo muestra como Latest 7.0.2 frente a Wanted 6.0.3.
  • Antes de agregar una dependencia (Russ Cox): ¿existe y es la que creo?, ¿quién la mantiene y cuándo fue el último release?, ¿tiene pruebas?, ¿cuántas dependencias trae?, ¿qué licencia tiene?, ¿la puedo reemplazar con unas pocas líneas propias o con la biblioteca estándar de Node? Kallpa no tiene ninguna dependencia de producción a propósito: node:http, node:sqlite y node:test alcanzan. En el proyecto, cada dependencia nueva se justifica en un ADR.
  • Cadena de suministro: typosquatting (un paquete con un nombre casi igual a uno popular), dependency confusion (un paquete público con el nombre de uno interno), un mantenedor comprometido o malicioso. El caso de referencia es xz utils (CVE-2024-3094): una puerta trasera que alguien metió en 2024 después de ganarse durante dos años la confianza del único mantenedor. Defensas: lockfile con hashes, npm audit signatures (firmas del registro y atestaciones de procedencia), SBOM (la lista de todo lo que va en el producto), SLSA, Scorecard.
  • Slopsquatting: un modelo de lenguaje sugiere npm install de un paquete que no existe, alguien registra ese nombre con código malicioso y espera. El estudio de Spracklen y otros generó 576 000 muestras de código con 16 modelos: en promedio, al menos 5.2 % de los paquetes sugeridos por modelos comerciales y 21.7 % de los sugeridos por modelos abiertos no existían, con más de 205 000 nombres inventados distintos. Basta con que un nombre inventado se repita para que valga la pena registrarlo. La defensa es la misma de siempre, aplicada sin excepción: no se instala nada que nadie haya verificado.
  • Leer npm audit con criterio. En la corrida del laboratorio (1 de noviembre de 2026) reporta 11 vulnerabilidades moderadas, todas por una versión de minimatch que usa ESLint, con riesgo de denegación de servicio. Son dependencias de desarrollo: no llegan al producto (npm audit --omit=dev encuentra 0) y para aprovecharla alguien tendría que controlar los patrones de archivos que recibe ESLint, que son los de su propia configuración. La decisión se escribe: se acepta el riesgo, se espera el release de ESLint que lo corrige y se vuelve a mirar.
  • Error común: correr npm audit fix --force para que el reporte quede en cero. En este caso propone instalar ESLint 4.0.0, una versión de 2017: al correrlo, los hallazgos suben en lugar de bajar y make check deja de pasar. Otro error común: poner npm audit sin nivel en el pipeline; sale con código 1 por cualquier hallazgo y el pipeline queda en rojo desde el primer día.

Laboratorio — Un pipeline de GitHub Actions para Kallpa

Guía: labs/README.md (secciones 4 y 8). Referencias: GitHub, Workflow syntax, actions/setup-node y About protected branches; npm, npm ci.

El runner de GitHub no puede usar la imagen del curso, así que el repositorio tiene que declarar sus herramientas: es la única semana en la que se instala algo con npm. Se instalan las mismas versiones que trae la imagen (ver env/Dockerfile), así el pipeline y el contenedor revisan lo mismo. Trabajen en 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.

  1. Fijar las herramientas como devDependencies, con versión exacta, y mirar lo que llegó:
    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
    npm ls                      # las seis directas
    npm ls --all | less         # todo el árbol
    Abran package-lock.json y busquen un paquete que no instalaron ustedes: ¿quién lo pidió (npm explain <paquete>)? Hagan commit de package.json y package-lock.json; node_modules/ ya está en .gitignore.
  2. Simular el runner. npm ci y make check con un PATH que no incluya las herramientas de la imagen:
    rm -rf node_modules && npm ci
    env PATH="$PWD/node_modules/.bin:/usr/local/bin:/usr/bin:/bin" make check
    Prueben también env PATH=/usr/local/bin:/usr/bin:/bin make check: sin node_modules/.bin en el PATH, make no encuentra tsc (make: tsc: No such file or directory), que es lo que pasa en el runner. Anoten este detalle para el workflow. Ojo: dentro del contenedor los import todavía encuentran los paquetes de la imagen, así que un paquete que falte en package.json solo se descubre en el pipeline.
  3. Escribir .github/workflows/ci.yml: que corra en cada pull_request y en cada push a main, con permissions: contents: read, un job en ubuntu-latest con timeout-minutes: 10, actions/checkout, actions/setup-node (node-version: 26, cache: npm), npm ci, un step que agregue node_modules/.bin al PATH (echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH") y make check. Fijen las actions al menos a su versión mayor y anoten en un comentario cómo se fijarían al SHA.
  4. Desde su computadora, subir la rama y abrir el pull request. Ver el check en la pestaña Checks, abrir el log de cada step y anotar cuánto tardó el job y cuánto de ese tiempo fue npm ci.
  5. Romper el pipeline a propósito: en otra rama, un cambio que no pase tsc (por ejemplo, pasar un string donde se espera un number) y otro que haga fallar una prueba. Abrir el pull request, encontrar en el log la línea exacta del error y cerrarlo sin hacer merge.
  6. Proteger main (en GitHub: Settings → Branches o Rules): exigir pull request con una aprobación y el status check del job de CI. Comprobar que el botón de merge queda bloqueado mientras el check está en rojo. Hacer merge del pull request del paso 4 con la aprobación de otra persona. Con una cuenta gratuita, la protección de ramas solo está disponible en repositorios públicos; en uno privado hace falta GitHub Pro (gratis con GitHub Education) o una organización con plan pagado.
  7. Dependencias con criterio, en el contenedor: correr npm audit, npm audit --omit=dev y npm audit --audit-level=high; echo $?, y npm outdated. Expliquen en la descripción del pull request qué reporta cada uno, por qué no se corre npm audit fix --force y qué nivel de npm audit pondrían en el pipeline (agréguenlo como step). Corran también npm audit signatures y anoten qué verifica.
  8. Paquetes que no existen. code/week-12/sugerencia.md trae un cambio escrito para la clase, como los que suele proponer una herramienta de IA para Kallpa, con tres npm install. Antes de instalar nada, revisen cada nombre con npm view <paquete> name version license repository.url time.modified y con la página del paquete en npmjs.com: ¿existe?, ¿es el que dice ser?, ¿quién lo mantiene?, ¿hace falta? Decidan cuál aceptarían (si alguno) y escriban la decisión como lo harían en su registro de uso de IA. time.modified es un dato del registro, no la fecha del último release: compárenlo con npm view <paquete> time --json.

Entregas

  • No hay entregas esta semana. P4 (projects/p4-entrega-continua/README.md), publicado en la semana 11, pide el pipeline de CI/CD de su proyecto: lo que hicieron hoy sobre Kallpa es el primer paso. Entrega en semana 14.

Preguntas de repaso

  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? ¿Qué cambiarían primero?
  2. ¿Qué diferencia hay entre npm install y npm ci? ¿Cuál va en el pipeline y por qué? ¿Qué pasa si alguien edita package.json y se olvida del lockfile?
  3. package.json dice "eslint": "^10.14.0". ¿Qué versiones acepta? ¿Y con "~10.14.0"? Den un ejemplo, con la ley de Hyrum, de cómo un release PATCH podría romper Kallpa.
  4. npm audit reporta 11 vulnerabilidades moderadas en dependencias de desarrollo y propone npm audit fix --force. ¿Qué harían y cómo lo documentarían? ¿Qué cambiaría si la vulnerabilidad estuviera en una dependencia que llega a producción?
  5. Una herramienta de IA les sugiere npm install de un paquete con un nombre razonable que nadie del equipo conoce. Describan, paso a paso, cómo lo verificarían. ¿Por qué no basta con comprobar que el nombre existe en el registro?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (16)Pruebas (7)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

escenarios.shDescargar escenarios.sh
#!/usr/bin/env bash
# Escenarios del apunte que no usan la red: cada uno trabaja en una copia de
# Kallpa dentro de build/. Los comandos que importan se imprimen con $.
set -u
unset MAKELEVEL MAKEFLAGS MFLAGS
HERE="$(cd "$(dirname "$0")" && pwd)"
KALLPA="${KALLPA:-$HERE/../../kallpa}"
GIT=(git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main)

# Con una tubería, el código de salida sería el del último comando: no se muestra.
show() {
  echo; echo "\$ $*"
  bash -c "$*" 2>&1
  local code=$?
  case "$*" in *\|*) ;; *) echo "[código de salida: $code]" ;; esac
}

fresh() {
  local dir="$HERE/build/$1"
  rm -rf "$dir"
  mkdir -p "$HERE/build"
  cp -r "$KALLPA" "$dir"
  rm -f "$dir"/data/*.db "$dir"/data/*.db-*
  cd "$dir" || exit 2
  "${GIT[@]}" init -q
  "${GIT[@]}" add -A
  "${GIT[@]}" commit -qm "Kallpa publicada"
}

case "${1:-}" in
  tipos)
    fresh tipos
    # Node solo quita los tipos: a formatSoles le llega un string, y como
    # cents / 100 lo convierte en número, el resultado es el mismo.
    sed -i 's/formatSoles(c.priceCents)/formatSoles(String(c.priceCents))/' src/notifications/notifier.ts
    show "git --no-pager diff -U0 | tail -3 | cut -c1-120"
    show "make test | tail -8"
    show "make typecheck"
    ;;
  colision)
    fresh colision
    # Rama A: cambia el nombre de formatSoles en todas las líneas donde se usa.
    "${GIT[@]}" switch -qc renombra-soles
    sed -i 's/formatSoles/centsToSoles/g' src/domain/pricing.ts src/notifications/notifier.ts test/pricing.test.ts
    "${GIT[@]}" commit -qam "Renombra formatSoles a centsToSoles"
    echo "--- rama renombra-soles"
    make check >/dev/null 2>&1; echo "make check: código de salida $?"
    # Rama B, desde el mismo punto: usa la función con su nombre de siempre.
    "${GIT[@]}" switch -q main
    "${GIT[@]}" switch -qc linea-de-caja
    cat >> src/domain/report.ts <<'TS'

// Una línea para imprimir la caja del día.
export function summaryLine(summary: DailySummary): string {
  return `${summary.date}: ${summary.bookings} reservas, ${formatSoles(summary.revenueCents)}`;
}
TS
    sed -i "1i import { formatSoles } from './pricing.ts';" src/domain/report.ts
    "${GIT[@]}" commit -qam "Agrega la línea de caja del día"
    echo "--- rama linea-de-caja"
    make check >/dev/null 2>&1; echo "make check: código de salida $?"
    echo "--- las dos juntas"
    "${GIT[@]}" switch -q main
    "${GIT[@]}" merge -q --no-edit renombra-soles
    export GIT_AUTHOR_NAME=Kallpa GIT_AUTHOR_EMAIL=kallpa@example.com GIT_COMMITTER_NAME=Kallpa GIT_COMMITTER_EMAIL=kallpa@example.com
    show "git merge --no-edit linea-de-caja"
    show "make check"
    ;;
  zona)
    cd "$HERE" || exit 2
    show "TZ=America/Lima node --test ejemplos/fecha-local.check.ts | grep -E '^(✔|✖) la fecha|^ℹ (pass|fail)'"
    show "TZ=UTC node --test ejemplos/fecha-local.check.ts | grep -E '^(✔|✖) la fecha|^ℹ (pass|fail)' | head -4"
    ;;
  *)
    echo "Uso: escenarios.sh tipos|colision|zona" >&2
    exit 2
    ;;
esac
eslint.config.jsDescargar eslint.config.js
// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint, sin información de tipos.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig(
  { ignores: ['build/', 'solution/', 'esperado/'] },
  js.configs.recommended,
  tseslint.configs.recommended,
);
flaky.tsDescargar flaky.ts
// La probabilidad de que un build salga en rojo sin que nadie haya roto nada,
// si cada una de `tests` pruebas falla sola con probabilidad `p` y las fallas
// son independientes.
export function falseRedProbability(p: number, tests: number): number {
  return 1 - (1 - p) ** tests;
}
lockfile-report.tsDescargar lockfile-report.ts
// Lo que se puede saber de un proyecto leyendo solo su package-lock.json.
// Uso: node lockfile-report.ts <package-lock.json>
import { readFileSync } from 'node:fs';

export type LockEntry = {
  version?: string;
  resolved?: string;
  integrity?: string;
  license?: string;
  dev?: boolean;
  hasInstallScript?: boolean;
  dependencies?: Record<string, string>;
  devDependencies?: Record<string, string>;
};

export type Lockfile = { lockfileVersion: number; packages: Record<string, LockEntry> };

const MARK = 'node_modules/';

export function nameOf(key: string): string {
  return key.slice(key.lastIndexOf(MARK) + MARK.length);
}

export function installed(lock: Lockfile): [string, LockEntry][] {
  return Object.entries(lock.packages).filter(([key]) => key !== '');
}

// La misma búsqueda que hace Node: primero node_modules de la propia carpeta,
// después la de cada carpeta de arriba.
export function resolveFrom(lock: Lockfile, fromKey: string, name: string): string | null {
  let base = fromKey;
  for (;;) {
    const candidate = `${base === '' ? '' : `${base}/`}${MARK}${name}`;
    if (candidate in lock.packages) return candidate;
    if (base === '') return null;
    const cut = base.lastIndexOf(MARK);
    base = cut <= 0 ? '' : base.slice(0, cut - 1);
  }
}

// Lo que un paquete instalado arrastra por sus `dependencies`, sin contarlo a
// él. No sigue peerDependencies: esas las pide, pero no las trae.
export function closure(lock: Lockfile, key: string): Set<string> {
  const seen = new Set<string>();
  const pending = [key];
  while (pending.length > 0) {
    const current = pending.pop() as string;
    for (const name of Object.keys(lock.packages[current]?.dependencies ?? {})) {
      const target = resolveFrom(lock, current, name);
      if (target !== null && !seen.has(target) && target !== key) {
        seen.add(target);
        pending.push(target);
      }
    }
  }
  return seen;
}

// Quién pide cada paquete, y con qué rango.
export function requesters(lock: Lockfile, name: string): { from: string; range: string }[] {
  return Object.entries(lock.packages)
    .filter(([key]) => key !== '')
    .flatMap(([key, entry]) => {
      const range = entry.dependencies?.[name];
      return range === undefined ? [] : [{ from: nameOf(key), range }];
    });
}

export type Report = {
  total: number;
  direct: { name: string; version: string; brings: number }[];
  nested: { name: string; versions: string[] }[];
  licenses: { license: string; count: number }[];
  installScripts: string[];
  hosts: string[];
  withoutIntegrity: string[];
};

export function report(lock: Lockfile): Report {
  const entries = installed(lock);
  const root = lock.packages[''] ?? {};
  const directNames = Object.keys({ ...root.dependencies, ...root.devDependencies }).sort();

  const direct = directNames.map((name) => {
    const key = `${MARK}${name}`;
    return { name, version: lock.packages[key]?.version ?? '?', brings: closure(lock, key).size };
  });

  const versions = new Map<string, Set<string>>();
  for (const [key, entry] of entries) {
    const name = nameOf(key);
    versions.set(name, (versions.get(name) ?? new Set()).add(entry.version ?? '?'));
  }
  const nested = [...versions]
    .filter(([, found]) => found.size > 1)
    .map(([name, found]) => ({ name, versions: [...found].sort() }))
    .sort((a, b) => a.name.localeCompare(b.name));

  const licenseCount = new Map<string, number>();
  for (const [, entry] of entries) {
    const license = entry.license ?? '(sin licencia declarada)';
    licenseCount.set(license, (licenseCount.get(license) ?? 0) + 1);
  }
  const licenses = [...licenseCount]
    .map(([license, count]) => ({ license, count }))
    .sort((a, b) => b.count - a.count || a.license.localeCompare(b.license));

  return {
    total: entries.length,
    direct,
    nested,
    licenses,
    installScripts: entries.filter(([, e]) => e.hasInstallScript === true).map(([key]) => nameOf(key)),
    hosts: [...new Set(entries.map(([, e]) => new URL(e.resolved ?? 'about:blank').host))].sort(),
    withoutIntegrity: entries.filter(([, e]) => e.integrity === undefined).map(([key]) => nameOf(key)),
  };
}

export function loadLockfile(path: string): Lockfile {
  return JSON.parse(readFileSync(path, 'utf8')) as Lockfile;
}

function print(r: Report): void {
  console.log(`Paquetes instalados: ${r.total}`);
  console.log('Directos y lo que traen por sus dependencies:');
  for (const d of r.direct) console.log(`  ${d.name}@${d.version}: ${d.brings}`);
  console.log('Con más de una versión en el árbol:');
  for (const n of r.nested) console.log(`  ${n.name}: ${n.versions.join(', ')}`);
  console.log('Licencias:');
  for (const l of r.licenses) console.log(`  ${String(l.count).padStart(3)} ${l.license}`);
  console.log(`Con script de instalación: ${r.installScripts.length}`);
  console.log(`Sin hash: ${r.withoutIntegrity.length}`);
  console.log(`Descargados de: ${r.hosts.join(', ')}`);
}

if (import.meta.main) {
  const path = process.argv[2];
  if (path === undefined) {
    console.error('Uso: node lockfile-report.ts <package-lock.json>');
    process.exit(2);
  }
  print(report(loadLockfile(path)));
}
registro.shDescargar registro.sh
#!/usr/bin/env bash
# Las corridas del apunte que usan el registro de npm. Cada comando se imprime
# con $, como en una terminal, y la salida es la de hoy: puede no coincidir con
# la del apunte, porque el registro cambia.
set -u
unset MAKELEVEL MAKEFLAGS MFLAGS
HERE="$(cd "$(dirname "$0")" && pwd)"
KALLPA="${KALLPA:-$HERE/../../kallpa}"
TOOLS=(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)
RUNNER_PATH_BASE=/usr/local/bin:/usr/bin:/bin

# Con una tubería, el código de salida sería el del último comando: no se muestra.
show() {
  echo; echo "\$ $*"
  bash -c "$*" 2>&1
  local code=$?
  case "$*" in *\|*) ;; *) echo "[código de salida: $code]" ;; esac
}

# El error de uso de npm trae toda la ayuda de la orden: se corta antes de ella.
short() { echo; echo "\$ $*"; bash -c "$*" 2>&1 | sed '/^npm error Clean install a project/,$d'; echo "[código de salida: ${PIPESTATUS[0]}]"; }

quiet_install() {
  local dir="$HERE/build/$1"
  rm -rf "$dir"
  mkdir -p "$HERE/build"
  cp -r "$KALLPA" "$dir"
  rm -f "$dir"/data/*.db "$dir"/data/*.db-*
  cd "$dir" || exit 2
  npm install --no-audit --no-fund --save-dev --save-exact "${TOOLS[@]}" >/dev/null 2>&1
}

case "${1:-}" in
  arbol)
    rm -rf "$HERE/build/arbol"; mkdir -p "$HERE/build"; cp -r "$KALLPA" "$HERE/build/arbol"
    cd "$HERE/build/arbol" || exit 2
    show "npm install --save-dev --save-exact ${TOOLS[*]}"
    show "npm ls"
    show "npm ls --all --parseable | wc -l"
    show "npm ls minimatch"
    show "npm explain minimatch | head -5"
    show "npm explain debug | head -4"
    show "wc -l package-lock.json"
    show "du -sh node_modules"
    ;;
  runner)
    quiet_install runner
    show "rm -rf node_modules && npm ci --no-fund"
    show "env PATH=\"\$PWD/node_modules/.bin:$RUNNER_PATH_BASE\" make check | tail -9"
    show "env PATH=$RUNNER_PATH_BASE make check"
    ;;
  lockfile)
    quiet_install lockfile
    cp package.json /tmp/w12-package.json; cp package-lock.json /tmp/w12-lock.json
    echo "--- alguien edita package.json y se olvida del lockfile"
    sed -i 's/"fast-check": "4.10.2"/"fast-check": "4.10.1"/' package.json
    short "npm ci"
    cp /tmp/w12-package.json package.json
    echo; echo "--- no hay lockfile"
    rm package-lock.json
    short "npm ci"
    cp /tmp/w12-lock.json package-lock.json
    echo; echo "--- el hash de ms en el lockfile no es el del paquete que llega"
    node -e "const fs=require('fs');const l=JSON.parse(fs.readFileSync('package-lock.json'));l.packages['node_modules/ms'].integrity=l.packages['node_modules/fast-check'].integrity;fs.writeFileSync('package-lock.json',JSON.stringify(l,null,2))"
    rm -rf node_modules; npm cache clean --force >/dev/null 2>&1
    show "npm ci 2>&1 | grep -E 'code EINTEGRITY|integrity checksum' | cut -c1-110"
    cp /tmp/w12-lock.json package-lock.json
    rm -f /tmp/w12-package.json /tmp/w12-lock.json
    ;;
  auditar)
    quiet_install auditar
    show "npm audit | grep -v -E 'Depends on|node_modules/'"
    show "npm audit --omit=dev"
    show "npm audit --audit-level=high > /dev/null"
    show "npm audit --audit-level=moderate > /dev/null"
    show "npm audit --json | jq -c '.metadata.vulnerabilities'"
    show "npm audit --json | jq -r '.vulnerabilities | to_entries[] | select(.value.isDirect) | .key'"
    show "npm outdated"
    show "npm audit signatures"
    ;;
  fuerza)
    quiet_install fuerza
    show "npm audit fix --force --dry-run 2>&1 | grep -E 'Updating'"
    show "npm audit fix --force --no-fund > /dev/null 2>&1"
    show "npm pkg get devDependencies"
    show "npm audit 2>&1 | tail -7 | head -1"
    show "env PATH=\"\$PWD/node_modules/.bin:$RUNNER_PATH_BASE\" make check"
    ;;
  peer)
    quiet_install peer
    show "npm view typescript-eslint@8.72.1 peerDependencies"
    show "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"
    show "env PATH=\"\$PWD/node_modules/.bin:$RUNNER_PATH_BASE\" make typecheck"
    show "env PATH=\"\$PWD/node_modules/.bin:$RUNNER_PATH_BASE\" make lint 2>&1 | head -3 | cut -c1-90"
    show "rm -rf node_modules && npm ci 2>&1 | cut -c1-90 | sed -n 1,5p"
    ;;
  rangos)
    rm -rf "$HERE/build/rangos"; mkdir -p "$HERE/build/rangos/antes" "$HERE/build/rangos/hoy"
    for dir in antes hoy; do
      echo '{ "name": "rangos", "private": true, "devDependencies": { "eslint": "^10.0.0" } }' > "$HERE/build/rangos/$dir/package.json"
    done
    cd "$HERE/build/rangos/antes" || exit 2
    show "npm install --no-audit --no-fund --before=2026-03-01"
    show "npm ls eslint --depth=0"
    cd "$HERE/build/rangos/hoy" || exit 2
    show "npm install --no-audit --no-fund"
    show "npm ls eslint --depth=0"
    show "npm view '@eslint/config-array@^0.23.5' version | tail -1"
    ;;
  falta)
    quiet_install falta
    show "npm uninstall --no-audit --no-fund @eslint/js"
    show "make check | tail -8"
    ;;
  sbom)
    quiet_install sbom
    show "npm sbom --sbom-format cyclonedx | jq -r '.bomFormat, .specVersion, (.components | length)'"
    show "npm sbom --sbom-format cyclonedx --omit=dev | jq '.components | length'"
    show "npm sbom --sbom-format cyclonedx | jq -c '.components[] | select(.name == \"minimatch\") | {name, version, purl, licenses}'"
    ;;
  paquetes)
    show "npm view request name version license repository.url time.modified deprecated"
    show "npm view fast-check name version license repository.url time.modified dependencies"
    show "npm view kallpa-slots-helper name version license repository.url time.modified | cut -c1-90"
    show "npm view debug@4.4.2 version 2>&1 | head -2"
    ;;
  *)
    echo "Uso: registro.sh arbol|runner|falta|lockfile|auditar|fuerza|peer|rangos|sbom|paquetes" >&2
    exit 2
    ;;
esac
revisar-workflow.tsDescargar revisar-workflow.ts
// Revisa un workflow de GitHub Actions contra las reglas de la semana, sin
// correrlo. No dice si el workflow funciona: eso solo lo dice el runner.
// Uso: node revisar-workflow.ts .github/workflows/ci.yml
import { readFileSync } from 'node:fs';
import { parse } from 'yaml';

export type Finding = { level: 'error' | 'aviso'; rule: string; message: string };

type Step = {
  uses?: unknown;
  run?: unknown;
  shell?: unknown;
  with?: Record<string, unknown>;
  ['continue-on-error']?: unknown;
};
type Job = { steps?: Step[]; ['continue-on-error']?: unknown };
type Workflow = {
  on?: unknown;
  permissions?: unknown;
  jobs?: Record<string, Job>;
};

const FULL_SHA = /^[0-9a-f]{40}$/;
const VERSION_TAG = /^v\d+(\.\d+){0,2}$/;

function eventsOf(on: unknown): Record<string, unknown> {
  if (typeof on === 'string') return { [on]: null };
  if (Array.isArray(on)) return Object.fromEntries(on.map((event) => [String(event), null]));
  return (on ?? {}) as Record<string, unknown>;
}

function pushesToMain(push: unknown): boolean {
  if (push === undefined) return false;
  const branches = (push as { branches?: unknown } | null)?.branches;
  return branches === undefined || (Array.isArray(branches) && branches.includes('main'));
}

export function checkWorkflow(workflow: Workflow): Finding[] {
  const findings: Finding[] = [];
  const error = (rule: string, message: string) => findings.push({ level: 'error', rule, message });
  const warn = (rule: string, message: string) => findings.push({ level: 'aviso', rule, message });

  const events = eventsOf(workflow.on);
  if (!('pull_request' in events)) error('evento-pr', 'No corre en pull_request: no revisa nada antes del merge.');
  if (!pushesToMain(events.push)) error('evento-push', 'No corre en push a main.');
  if ('pull_request_target' in events) {
    error('pull-request-target', 'pull_request_target corre con los permisos de la rama base; con código de un fork es peligroso.');
  }

  const permissions = workflow.permissions;
  if (permissions === undefined) error('permisos', 'Falta permissions: el GITHUB_TOKEN queda con los permisos por omisión.');
  if (permissions === 'write-all') error('permisos', 'permissions: write-all da más de lo que un job de revisión necesita.');

  const jobs = Object.values(workflow.jobs ?? {});
  const steps = jobs.flatMap((job) => job.steps ?? []);
  const runs = steps.flatMap((step) => (typeof step.run === 'string' ? [step] : []));
  const indexOfRun = (needle: RegExp) => steps.findIndex((step) => typeof step.run === 'string' && needle.test(step.run));

  for (const job of jobs) {
    if (job['continue-on-error'] === true) error('sigue-igual', 'continue-on-error en el job: nunca sale en rojo.');
  }
  for (const step of steps) {
    if (step['continue-on-error'] === true) error('sigue-igual', 'continue-on-error en un step: su falla no cuenta.');
  }

  const install = indexOfRun(/\bnpm ci\b/);
  const path = indexOfRun(/GITHUB_PATH/);
  const check = indexOfRun(/\bmake check\b/);
  if (indexOfRun(/\bnpm (install|i)\b/) !== -1) error('npm-install', 'npm install puede cambiar el lockfile; en el pipeline va npm ci.');
  if (install === -1) error('npm-ci', 'Falta npm ci: en el runner no están las herramientas.');
  if (check === -1) error('make-check', 'Falta make check: el pipeline tiene que correr lo mismo que ustedes.');
  if (path === -1 || !/node_modules\/\.bin/.test(String(steps[path]?.run))) {
    error('path', 'Falta agregar node_modules/.bin al PATH: make no encontraría tsc.');
  }
  if (install !== -1 && check !== -1 && install > check) error('orden', 'npm ci va antes de make check.');
  if (path !== -1 && check !== -1 && path > check) error('orden', 'El PATH se agrega antes de make check.');

  for (const step of runs) {
    const text = step.run as string;
    if (/\$\{\{\s*secrets\./.test(text)) error('secret', `Un secret dentro de un run puede terminar en el log: ${text.trim()}`);
    if (/\|/.test(text) && /\bmake check\b/.test(text) && step.shell !== 'bash') {
      error('tuberia', 'Sin shell: bash no hay pipefail, y el último comando decide si el step pasa.');
    }
    if (/\bnpm audit\b(?!.*(signatures|--audit-level))/.test(text)) {
      error('audit', 'npm audit sin --audit-level sale con 1 por cualquier hallazgo.');
    }
    if (/\baudit fix --force\b/.test(text)) error('audit', 'npm audit fix --force en el pipeline cambia versiones sin revisión.');
  }

  for (const step of steps) {
    if (typeof step.uses !== 'string') continue;
    const [action, ref] = step.uses.split('@');
    if (ref === undefined || (!FULL_SHA.test(ref) && !VERSION_TAG.test(ref))) {
      error('fijar', `${step.uses} no está fijada a una versión.`);
    } else if (!FULL_SHA.test(ref)) {
      warn('fijar', `${step.uses} usa una etiqueta, que se puede mover; lo más seguro es el SHA del commit.`);
    }
    if (action === 'actions/setup-node' && String(step.with?.['node-version']) !== '26') {
      error('node', 'setup-node tiene que pedir node-version: 26, la misma del contenedor.');
    }
  }
  return findings;
}

export function checkFile(path: string): Finding[] {
  return checkWorkflow(parse(readFileSync(path, 'utf8')) as Workflow);
}

if (import.meta.main) {
  const path = process.argv[2];
  if (path === undefined) {
    console.error('Uso: node revisar-workflow.ts <workflow.yml>');
    process.exit(2);
  }
  const findings = checkFile(path);
  for (const f of findings) console.log(`${f.level}  ${f.rule}: ${f.message}`);
  const errors = findings.filter((f) => f.level === 'error').length;
  console.log(`${errors} errores, ${findings.length - errors} avisos`);
  process.exit(errors > 0 ? 1 : 0);
}
runner-limpio.shDescargar runner-limpio.sh
#!/bin/sh
# Lo más parecido a un runner que se puede tener sin GitHub: la imagen oficial
# de Node, sin las herramientas del curso. Instala make y git, copia Kallpa,
# declara las herramientas como en esperado/ y corre npm ci y make check.
# Se corre en la terminal de su computadora, donde está Docker:
#
#   sh runner-limpio.sh                      todas las herramientas declaradas
#   sh runner-limpio.sh @eslint/js           sin declarar ese paquete
#
# Dentro del contenedor del curso este error no se ve: los import encuentran
# los paquetes de la imagen aunque falten en package.json.
HERE="$(cd "$(dirname "$0")" && pwd)"
docker run --rm -v "$HERE/../..:/curso:ro" -e FALTA="${1:-}" node:26-trixie-slim bash -c '
  apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends make >/dev/null 2>&1
  cp -r /curso/kallpa /tmp/kallpa && cd /tmp/kallpa
  cp /curso/code/week-12/esperado/package.json /curso/code/week-12/esperado/package-lock.json .
  export PATH="$PWD/node_modules/.bin:$PATH"
  if [ -n "$FALTA" ]; then
    echo "\$ npm uninstall --no-audit --no-fund $FALTA | grep -E \"^(added|removed)\""
    npm uninstall --no-audit --no-fund "$FALTA" 2>&1 | grep -E "^(added|removed)"
  else
    echo "\$ npm ci --no-audit --no-fund | grep -E \"^added\""
    npm ci --no-audit --no-fund 2>&1 | grep -E "^added"
  fi
  echo "\$ node --version; which tsc"
  node --version; which tsc
  echo "\$ make check | grep -v -E \"^ *(✔|▶)\" | cut -c1-110"
  make check 2>&1 | grep -v -E "^ *(✔|▶)" | cut -c1-110
  echo "[código de salida: ${PIPESTATUS[0]}]"
'
semver-range.tsDescargar semver-range.ts
// Qué versiones acepta un rango de package.json. Es una versión reducida de lo
// que hace npm: solo números (sin prereleases) y solo tres formas de rango:
// exacta, con ^ y con ~. Sirve para entender la regla, no para reemplazar al
// paquete semver.
export type Version = { major: number; minor: number; patch: number };

const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;

export function parseVersion(text: string): Version | null {
  const match = VERSION.exec(text);
  if (match === null) return null;
  return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
}

export function compareVersions(a: Version, b: Version): number {
  return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
}

export type Bounds = { from: Version; below: Version | null };

// El intervalo [from, below) que acepta un rango. `below` es null en una
// versión exacta: ahí el intervalo tiene un solo elemento.
export function boundsOf(range: string): Bounds | null {
  const operator = range[0] === '^' || range[0] === '~' ? range[0] : '';
  const from = parseVersion(range.slice(operator.length));
  if (from === null) return null;
  if (operator === '') return { from, below: null };
  if (operator === '~') return { from, below: { major: from.major, minor: from.minor + 1, patch: 0 } };
  // ^ deja fijo el primer número distinto de cero: en 0.x, un MINOR puede romper
  // la compatibilidad, así que ^0.23.5 no llega a 0.24.0.
  if (from.major > 0) return { from, below: { major: from.major + 1, minor: 0, patch: 0 } };
  if (from.minor > 0) return { from, below: { major: 0, minor: from.minor + 1, patch: 0 } };
  return { from, below: { major: 0, minor: 0, patch: from.patch + 1 } };
}

export function satisfies(version: string, range: string): boolean {
  const parsed = parseVersion(version);
  const bounds = boundsOf(range);
  if (parsed === null || bounds === null) return false;
  if (bounds.below === null) return compareVersions(parsed, bounds.from) === 0;
  return compareVersions(parsed, bounds.from) >= 0 && compareVersions(parsed, bounds.below) < 0;
}
sugerencia.mdDescargar sugerencia.md
Ilustración escrita para la clase: un cambio como los que suele proponer un agente, no la salida de una herramienta concreta.

# Sugerencia: confirmar las reservas de verdad

**Lo que se pidió.** El aviso de reserva confirmada hoy solo se escribe en la
consola del servidor. Que salga por WhatsApp, con la hora bien escrita, y de
paso que el cuerpo de `POST /api/bookings` se valide mejor.

**Lo que se propone.** Hay que instalar tres paquetes:

```sh
npm install zod
npm install left-pad
npm install whatsapp-cloud-notify-pe
```

Con eso, los cambios son pocos:

```ts
// src/http/handlers.ts: validar el cuerpo con un esquema
import { z } from 'zod';

const bookingBody = z.object({
  courtId: z.number().int().positive(),
  memberId: z.number().int().positive(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  startHour: z.number().int(),
  hours: z.number().int().default(1),
  teamName: z.string().min(1).max(40),
});
```

```ts
// src/notifications/notifier.ts: la hora con dos cifras y el envío real
import leftPad from 'left-pad';
import { WhatsAppCloudNotifier } from 'whatsapp-cloud-notify-pe';

const hour = `${leftPad(c.startHour, 2, '0')}:00`;

const client = new WhatsAppCloudNotifier({ token: process.env.WHATSAPP_TOKEN });
await client.sendText(member.phone, confirmationText(c));
```

> "Son paquetes estándar, muy usados en proyectos de Node y bien mantenidos.
> Todas las pruebas deberían seguir pasando."

## Qué hacer con esto

Antes de instalar nada, en el contenedor, revisen cada nombre:

```sh
npm view <paquete> name version license repository.url time.modified
```

Miren también su página en npmjs.com y su repositorio. Para cada uno respondan:
¿existe?, ¿es el que dice ser?, ¿quién lo mantiene?, ¿hace falta? Decidan cuál
aceptarían (si alguno) y escriban la decisión como lo harían en su registro de
uso de IA.
tools.tsDescargar tools.ts
// Utilidades de las pruebas: correr un comando y juntar lo que imprime.
import { spawnSync } from 'node:child_process';

export const HERE = import.meta.dirname;

export type RunResult = { code: number; output: string };

export function run(command: string, args: string[], env: Record<string, string> = {}): RunResult {
  // Sin NODE_TEST_CONTEXT: si no, un node --test hijo no corre sus archivos.
  const merged: Record<string, string | undefined> = { ...process.env, ...env };
  delete merged.NODE_TEST_CONTEXT;
  delete merged.MAKELEVEL;
  delete merged.MAKEFLAGS;
  delete merged.MFLAGS;
  const result = spawnSync(command, args, { cwd: HERE, env: merged, encoding: 'utf8' });
  return { code: result.status ?? -1, output: `${result.stdout}${result.stderr}` };
}
ejemplos/fecha-local.check.tsDescargar ejemplos/fecha-local.check.ts
// Esta prueba no se llama *.test.ts porque falla a propósito cuando la máquina
// está en UTC: la corre zona-horaria.test.ts, y a mano `make zona`.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { bookingDateFromMachine, bookingDateInLima } from './fecha-local.ts';

// Las 21:00 del viernes 13 de marzo en Lima: en UTC ya es sábado.
const INSTANT = new Date('2026-03-14T02:00:00Z');

test('la fecha según la máquina', () => {
  assert.equal(bookingDateFromMachine(INSTANT), '2026-03-13');
});

test('la fecha en Lima', () => {
  assert.equal(bookingDateInLima(INSTANT), '2026-03-13');
});
ejemplos/fecha-local.tsDescargar ejemplos/fecha-local.ts
import { limaTime } from '../../../kallpa/src/domain/clock.ts';

// Mal: getDate y compañía usan la zona horaria de la máquina que corre el código.
export function bookingDateFromMachine(instant: Date): string {
  const month = String(instant.getMonth() + 1).padStart(2, '0');
  const day = String(instant.getDate()).padStart(2, '0');
  return `${instant.getFullYear()}-${month}-${day}`;
}

// Bien: la zona es parte del programa, como en src/domain/clock.ts de Kallpa.
export function bookingDateInLima(instant: Date): string {
  return limaTime(instant).date;
}
ejemplos/workflow-incompleto.ymlDescargar 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
ejemplos/workflow-peligroso.ymlDescargar ejemplos/workflow-peligroso.yml
# Cada línea marcada es un error de esta semana. No lo copien.
name: CI

on:
  pull_request_target:          # corre con los permisos de la rama base
  push:
    branches: [main]

permissions: write-all          # más permisos de los que necesita

jobs:
  check:
    runs-on: ubuntu-latest
    continue-on-error: true     # el job nunca sale en rojo
    steps:
      - uses: actions/checkout@main     # una rama: cambia todos los días
      - uses: tj-actions/changed-files@v45   # una etiqueta que se puede mover
      - uses: actions/setup-node@v7
        with:
          node-version: 26
      - run: npm install        # puede cambiar lo que dice el lockfile
      - run: echo "$PATH_NODE" >> "$GITHUB_PATH"
      - run: echo "${{ secrets.DEPLOY_TOKEN }}"   # un secret en el log
      - run: make check | tee check.log     # sin pipefail, tee esconde el error
      - run: npm audit          # en rojo por cualquier hallazgo, desde el primer día
esperado/package-lock.jsonDescargar esperado/package-lock.json
{
  "name": "kallpa",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "kallpa",
      "version": "1.0.0",
      "devDependencies": {
        "@eslint/js": "10.0.1",
        "@types/node": "26.6.6",
        "eslint": "10.14.0",
        "fast-check": "4.10.2",
        "typescript": "6.0.3",
        "typescript-eslint": "8.72.1"
      },
      "engines": {
        "node": ">=26"
      }
    },
    "node_modules/@cacheable/memory": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
      "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@cacheable/utils": "^2.5.0",
        "@keyv/bigmap": "^1.3.1",
        "hookified": "^1.15.1",
        "keyv": "^5.6.0"
      }
    },
    "node_modules/@cacheable/utils": {
      "version": "2.5.0",
      "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
      "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "hashery": "^1.5.1",
        "keyv": "^5.6.0"
      }
    },
    "node_modules/@eslint-community/eslint-utils": {
      "version": "4.10.1",
      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
      "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "eslint-visitor-keys": "^3.4.3"
      },
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      },
      "peerDependencies": {
        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
      }
    },
    "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
      "version": "3.4.3",
      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/@eslint-community/regexpp": {
      "version": "4.12.2",
      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
      "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
      }
    },
    "node_modules/@eslint/config-array": {
      "version": "0.23.5",
      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
      "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@eslint/object-schema": "^3.0.5",
        "debug": "^4.3.1",
        "minimatch": "^10.2.4"
      },
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      }
    },
    "node_modules/@eslint/config-helpers": {
      "version": "0.7.0",
      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
      "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@eslint/core": "^1.2.1"
      },
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      }
    },
    "node_modules/@eslint/core": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
      "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@types/json-schema": "^7.0.15"
      },
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      }
    },
    "node_modules/@eslint/js": {
      "version": "10.0.1",
      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
      "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      },
      "funding": {
        "url": "https://eslint.org/donate"
      },
      "peerDependencies": {
        "eslint": "^10.0.0"
      },
      "peerDependenciesMeta": {
        "eslint": {
          "optional": true
        }
      }
    },
    "node_modules/@eslint/object-schema": {
      "version": "3.0.5",
      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
      "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      }
    },
    "node_modules/@eslint/plugin-kit": {
      "version": "0.7.3",
      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
      "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@eslint/core": "^1.2.1",
        "levn": "^0.4.1"
      },
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      }
    },
    "node_modules/@humanfs/core": {
      "version": "0.19.2",
      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
      "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@humanfs/types": "^0.15.0"
      },
      "engines": {
        "node": ">=18.18.0"
      }
    },
    "node_modules/@humanfs/node": {
      "version": "0.16.8",
      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
      "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@humanfs/core": "^0.19.2",
        "@humanfs/types": "^0.15.0",
        "@humanwhocodes/retry": "^0.4.0"
      },
      "engines": {
        "node": ">=18.18.0"
      }
    },
    "node_modules/@humanfs/types": {
      "version": "0.15.0",
      "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
      "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=18.18.0"
      }
    },
    "node_modules/@humanwhocodes/module-importer": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=12.22"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/nzakas"
      }
    },
    "node_modules/@humanwhocodes/retry": {
      "version": "0.4.3",
      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=18.18"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/nzakas"
      }
    },
    "node_modules/@keyv/bigmap": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
      "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "hashery": "^1.4.0",
        "hookified": "^1.15.0"
      },
      "engines": {
        "node": ">= 18"
      },
      "peerDependencies": {
        "keyv": "^5.6.0"
      }
    },
    "node_modules/@keyv/serialize": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
      "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/esrecurse": {
      "version": "4.3.1",
      "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
      "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/estree": {
      "version": "1.0.10",
      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
      "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/json-schema": {
      "version": "7.0.15",
      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/node": {
      "version": "26.6.6",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
      "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "undici-types": "~8.9.0"
      }
    },
    "node_modules/@typescript-eslint/eslint-plugin": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz",
      "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@eslint-community/regexpp": "^4.12.2",
        "@typescript-eslint/scope-manager": "8.72.1",
        "@typescript-eslint/type-utils": "8.72.1",
        "@typescript-eslint/utils": "8.72.1",
        "@typescript-eslint/visitor-keys": "8.72.1",
        "ignore": "^7.0.5",
        "natural-compare": "^1.4.0",
        "ts-api-utils": "^2.5.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "@typescript-eslint/parser": "^8.72.1",
        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
        "typescript": ">=4.8.4 <6.1.0"
      }
    },
    "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
      "version": "7.0.14",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz",
      "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 4"
      }
    },
    "node_modules/@typescript-eslint/parser": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz",
      "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/scope-manager": "8.72.1",
        "@typescript-eslint/types": "8.72.1",
        "@typescript-eslint/typescript-estree": "8.72.1",
        "@typescript-eslint/visitor-keys": "8.72.1",
        "debug": "^4.4.3"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
        "typescript": ">=4.8.4 <6.1.0"
      }
    },
    "node_modules/@typescript-eslint/project-service": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
      "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/tsconfig-utils": "^8.72.1",
        "@typescript-eslint/types": "^8.72.1",
        "debug": "^4.4.3"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "typescript": ">=4.8.4 <6.1.0"
      }
    },
    "node_modules/@typescript-eslint/scope-manager": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
      "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/types": "8.72.1",
        "@typescript-eslint/visitor-keys": "8.72.1"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      }
    },
    "node_modules/@typescript-eslint/tsconfig-utils": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
      "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "typescript": ">=4.8.4 <6.1.0"
      }
    },
    "node_modules/@typescript-eslint/type-utils": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz",
      "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/types": "8.72.1",
        "@typescript-eslint/typescript-estree": "8.72.1",
        "@typescript-eslint/utils": "8.72.1",
        "debug": "^4.4.3",
        "ts-api-utils": "^2.5.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
        "typescript": ">=4.8.4 <6.1.0"
      }
    },
    "node_modules/@typescript-eslint/types": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
      "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      }
    },
    "node_modules/@typescript-eslint/typescript-estree": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
      "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/project-service": "8.72.1",
        "@typescript-eslint/tsconfig-utils": "8.72.1",
        "@typescript-eslint/types": "8.72.1",
        "@typescript-eslint/visitor-keys": "8.72.1",
        "debug": "^4.4.3",
        "minimatch": "^10.2.2",
        "semver": "^7.7.3",
        "tinyglobby": "^0.2.15",
        "ts-api-utils": "^2.5.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "typescript": ">=4.8.4 <6.1.0"
      }
    },
    "node_modules/@typescript-eslint/utils": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
      "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@eslint-community/eslint-utils": "^4.9.1",
        "@typescript-eslint/scope-manager": "8.72.1",
        "@typescript-eslint/types": "8.72.1",
        "@typescript-eslint/typescript-estree": "8.72.1"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
        "typescript": ">=4.8.4 <6.1.0"
      }
    },
    "node_modules/@typescript-eslint/visitor-keys": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
      "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/types": "8.72.1",
        "eslint-visitor-keys": "^5.0.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      }
    },
    "node_modules/acorn": {
      "version": "8.19.0",
      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
      "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
      "dev": true,
      "license": "MIT",
      "bin": {
        "acorn": "bin/acorn"
      },
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/acorn-jsx": {
      "version": "5.3.2",
      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
      "dev": true,
      "license": "MIT",
      "peerDependencies": {
        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
      }
    },
    "node_modules/ajv": {
      "version": "6.15.0",
      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
      "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fast-deep-equal": "^3.1.1",
        "fast-json-stable-stringify": "^2.0.0",
        "json-schema-traverse": "^0.4.1",
        "uri-js": "^4.2.2"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/epoberezkin"
      }
    },
    "node_modules/balanced-match": {
      "version": "4.0.4",
      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "18 || 20 || >=22"
      }
    },
    "node_modules/brace-expansion": {
      "version": "5.0.12",
      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
      "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "balanced-match": "^4.0.2"
      },
      "engines": {
        "node": "20 || >=22"
      }
    },
    "node_modules/cacheable": {
      "version": "2.5.0",
      "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
      "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@cacheable/memory": "^2.2.0",
        "@cacheable/utils": "^2.5.0",
        "hookified": "^1.15.0",
        "keyv": "^5.6.0",
        "qified": "^0.10.1"
      }
    },
    "node_modules/cross-spawn": {
      "version": "7.0.6",
      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "path-key": "^3.1.0",
        "shebang-command": "^2.0.0",
        "which": "^2.0.1"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/debug": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.3"
      },
      "engines": {
        "node": ">=6.0"
      },
      "peerDependenciesMeta": {
        "supports-color": {
          "optional": true
        }
      }
    },
    "node_modules/deep-is": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/escape-string-regexp": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/eslint": {
      "version": "10.14.0",
      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
      "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
      "dev": true,
      "license": "MIT",
      "workspaces": [
        "packages/*"
      ],
      "dependencies": {
        "@eslint-community/eslint-utils": "^4.8.0",
        "@eslint-community/regexpp": "^4.12.2",
        "@eslint/config-array": "^0.23.5",
        "@eslint/config-helpers": "^0.7.0",
        "@eslint/core": "^1.2.1",
        "@eslint/plugin-kit": "^0.7.3",
        "@humanfs/node": "^0.16.8",
        "@humanwhocodes/module-importer": "^1.0.1",
        "@humanwhocodes/retry": "^0.4.2",
        "@types/estree": "^1.0.6",
        "ajv": "^6.14.0",
        "cross-spawn": "^7.0.6",
        "debug": "^4.3.2",
        "escape-string-regexp": "^4.0.0",
        "eslint-scope": "^9.1.2",
        "eslint-visitor-keys": "^5.0.1",
        "espree": "^11.2.0",
        "esquery": "^1.7.0",
        "esutils": "^2.0.2",
        "fast-deep-equal": "^3.1.3",
        "file-entry-cache": "11.1.5 || >11.1.6 <12",
        "find-up": "^5.0.0",
        "glob-parent": "^6.0.2",
        "ignore": "^5.2.0",
        "imurmurhash": "^0.1.4",
        "is-glob": "^4.0.0",
        "json-stable-stringify-without-jsonify": "^1.0.1",
        "minimatch": "^10.2.6",
        "natural-compare": "^1.4.0",
        "optionator": "^0.9.3"
      },
      "bin": {
        "eslint": "bin/eslint.js"
      },
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      },
      "funding": {
        "url": "https://eslint.org/donate"
      },
      "peerDependencies": {
        "jiti": "*"
      },
      "peerDependenciesMeta": {
        "jiti": {
          "optional": true
        }
      }
    },
    "node_modules/eslint-scope": {
      "version": "9.1.2",
      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
      "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "@types/esrecurse": "^4.3.1",
        "@types/estree": "^1.0.8",
        "esrecurse": "^4.3.0",
        "estraverse": "^5.2.0"
      },
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/eslint-visitor-keys": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
      "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/espree": {
      "version": "11.2.0",
      "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
      "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "acorn": "^8.16.0",
        "acorn-jsx": "^5.3.2",
        "eslint-visitor-keys": "^5.0.1"
      },
      "engines": {
        "node": "^20.19.0 || ^22.13.0 || >=24"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/esquery": {
      "version": "1.7.0",
      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
      "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
      "dev": true,
      "license": "BSD-3-Clause",
      "dependencies": {
        "estraverse": "^5.1.0"
      },
      "engines": {
        "node": ">=0.10"
      }
    },
    "node_modules/esrecurse": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "estraverse": "^5.2.0"
      },
      "engines": {
        "node": ">=4.0"
      }
    },
    "node_modules/estraverse": {
      "version": "5.3.0",
      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
      "dev": true,
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=4.0"
      }
    },
    "node_modules/esutils": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
      "dev": true,
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/fast-check": {
      "version": "4.10.2",
      "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz",
      "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==",
      "dev": true,
      "funding": [
        {
          "type": "individual",
          "url": "https://github.com/sponsors/dubzzz"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fast-check"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "pure-rand": "^8.0.0"
      },
      "engines": {
        "node": ">=12.17.0"
      }
    },
    "node_modules/fast-deep-equal": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-json-stable-stringify": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-levenshtein": {
      "version": "2.0.6",
      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fdir": {
      "version": "6.5.0",
      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12.0.0"
      },
      "peerDependencies": {
        "picomatch": "^3 || ^4"
      },
      "peerDependenciesMeta": {
        "picomatch": {
          "optional": true
        }
      }
    },
    "node_modules/file-entry-cache": {
      "version": "11.1.5",
      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
      "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "flat-cache": "^6.1.23"
      }
    },
    "node_modules/find-up": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "locate-path": "^6.0.0",
        "path-exists": "^4.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/flat-cache": {
      "version": "6.1.23",
      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
      "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "cacheable": "^2.5.0",
        "flatted": "^3.4.2",
        "hookified": "^1.15.0"
      }
    },
    "node_modules/flatted": {
      "version": "3.4.4",
      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
      "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/glob-parent": {
      "version": "6.0.2",
      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "is-glob": "^4.0.3"
      },
      "engines": {
        "node": ">=10.13.0"
      }
    },
    "node_modules/hashery": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
      "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "hookified": "^1.15.0"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/hookified": {
      "version": "1.15.1",
      "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
      "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/ignore": {
      "version": "5.3.2",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 4"
      }
    },
    "node_modules/imurmurhash": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.8.19"
      }
    },
    "node_modules/is-extglob": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-glob": {
      "version": "4.0.3",
      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-extglob": "^2.1.1"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/isexe": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/json-schema-traverse": {
      "version": "0.4.1",
      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/json-stable-stringify-without-jsonify": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/keyv": {
      "version": "5.6.0",
      "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
      "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@keyv/serialize": "^1.1.1"
      }
    },
    "node_modules/levn": {
      "version": "0.4.1",
      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "prelude-ls": "^1.2.1",
        "type-check": "~0.4.0"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/locate-path": {
      "version": "6.0.0",
      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "p-locate": "^5.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "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"
      },
      "engines": {
        "node": "18 || 20 || >=22"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/natural-compare": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/optionator": {
      "version": "0.9.4",
      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "deep-is": "^0.1.3",
        "fast-levenshtein": "^2.0.6",
        "levn": "^0.4.1",
        "prelude-ls": "^1.2.1",
        "type-check": "^0.4.0",
        "word-wrap": "^1.2.5"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/p-limit": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "yocto-queue": "^0.1.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/p-locate": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "p-limit": "^3.0.2"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/path-exists": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/path-key": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/picomatch": {
      "version": "4.0.7",
      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/prelude-ls": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/punycode": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/pure-rand": {
      "version": "8.4.2",
      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
      "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
      "dev": true,
      "funding": [
        {
          "type": "individual",
          "url": "https://github.com/sponsors/dubzzz"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fast-check"
        }
      ],
      "license": "MIT"
    },
    "node_modules/qified": {
      "version": "0.10.1",
      "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
      "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "hookified": "^2.1.1"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/qified/node_modules/hookified": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
      "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/semver": {
      "version": "7.8.5",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
      "dev": true,
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/shebang-command": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "shebang-regex": "^3.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/shebang-regex": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/tinyglobby": {
      "version": "0.2.17",
      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fdir": "^6.5.0",
        "picomatch": "^4.0.4"
      },
      "engines": {
        "node": ">=12.0.0"
      },
      "funding": {
        "url": "https://github.com/sponsors/SuperchupuDev"
      }
    },
    "node_modules/ts-api-utils": {
      "version": "2.5.0",
      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
      "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18.12"
      },
      "peerDependencies": {
        "typescript": ">=4.8.4"
      }
    },
    "node_modules/type-check": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "prelude-ls": "^1.2.1"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/typescript": {
      "version": "6.0.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
      "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/typescript-eslint": {
      "version": "8.72.1",
      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz",
      "integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/eslint-plugin": "8.72.1",
        "@typescript-eslint/parser": "8.72.1",
        "@typescript-eslint/typescript-estree": "8.72.1",
        "@typescript-eslint/utils": "8.72.1"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
        "typescript": ">=4.8.4 <6.1.0"
      }
    },
    "node_modules/undici-types": {
      "version": "8.9.0",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
      "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/uri-js": {
      "version": "4.4.1",
      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "punycode": "^2.1.0"
      }
    },
    "node_modules/which": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "isexe": "^2.0.0"
      },
      "bin": {
        "node-which": "bin/node-which"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/word-wrap": {
      "version": "1.2.5",
      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/yocto-queue": {
      "version": "0.1.0",
      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    }
  }
}
esperado/package.jsonDescargar esperado/package.json
{
  "name": "kallpa",
  "version": "1.0.0",
  "private": true,
  "description": "Reservas de canchas del complejo deportivo Kallpa",
  "type": "module",
  "engines": {
    "node": ">=26"
  },
  "scripts": {
    "start": "node src/main.ts",
    "seed": "node src/seed.ts",
    "test": "node --test \"test/**/*.test.ts\"",
    "typecheck": "tsc -p .",
    "lint": "eslint ."
  },
  "devDependencies": {
    "@eslint/js": "10.0.1",
    "@types/node": "26.6.6",
    "eslint": "10.14.0",
    "fast-check": "4.10.2",
    "typescript": "6.0.3",
    "typescript-eslint": "8.72.1"
  }
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

apunte.test.tsDescargar apunte.test.ts
// Lo que el apunte enlaza existe, y lo que muestra de los ejemplos sigue ahí.
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { HERE } from './tools.ts';

const NOTES = resolve(HERE, '../../notes');
const text = readFileSync(resolve(NOTES, 'week-12.md'), 'utf8');

test('los enlaces relativos del apunte apuntan a archivos que existen', () => {
  const links = [...text.matchAll(/\]\((\.\.\/[^)#\s]+)/g)].map((m) => m[1] as string);
  assert.ok(links.length > 10);
  for (const link of links) assert.ok(existsSync(resolve(NOTES, link)), link);
});

test('el apunte reproduce el primer intento tal como está en el archivo', () => {
  const workflow = readFileSync(resolve(HERE, 'ejemplos/workflow-incompleto.yml'), 'utf8');
  assert.ok(text.includes(workflow.trim()));
});

test('el apunte no muestra la solución del paso 3 ni la de la sugerencia', () => {
  const suggestion = readFileSync(resolve(HERE, 'sugerencia.md'), 'utf8');
  for (const [, name] of suggestion.matchAll(/^npm install (\S+)$/gm)) {
    assert.ok(!text.includes(`npm view ${name} `), name);
  }
  assert.doesNotMatch(text, /npm audit --omit=dev --audit-level/);
});
escenarios.test.tsDescargar escenarios.test.ts
// Los escenarios del apunte que no usan la red: lo que afirma el texto es lo
// que pasa al correrlos.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { run } from './tools.ts';

test('tipos: las pruebas pasan y tsc no', () => {
  const { output } = run('bash', ['escenarios.sh', 'tipos']);
  assert.match(output, /ℹ pass 40/);
  assert.match(output, /ℹ fail 0/);
  assert.match(output, /notifier\.ts\(21,\d+\): error TS2345/);
});

test('colisión: cada rama en verde, el merge sin conflicto y el resultado en rojo', () => {
  const { output } = run('bash', ['escenarios.sh', 'colision']);
  assert.match(output, /--- rama renombra-soles\nmake check: código de salida 0/);
  assert.match(output, /--- rama linea-de-caja\nmake check: código de salida 0/);
  assert.match(output, /Merge made by/);
  assert.doesNotMatch(output, /CONFLICT/);
  assert.match(output, /report\.ts\(1,10\): error TS2305/);
});

test('zona: la misma prueba pasa en Lima y falla en UTC', () => {
  const file = 'ejemplos/fecha-local.check.ts';
  assert.equal(run('node', ['--test', file], { TZ: 'America/Lima' }).code, 0);
  const utc = run('node', ['--test', file], { TZ: 'UTC' });
  assert.equal(utc.code, 1);
  assert.match(utc.output, /✔ la fecha en Lima/);
  assert.match(utc.output, /✖ la fecha según la máquina/);
});
flaky.test.tsDescargar flaky.test.ts
// La cuenta del apunte: pocas pruebas inestables bastan para un build inútil.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { falseRedProbability } from './flaky.ts';

const rounded = (p: number) => Math.round(p * 1000) / 10;

test('40 pruebas con 1 % de falla: rojo en un tercio de los builds', () => {
  assert.equal(rounded(falseRedProbability(0.01, 40)), 33.1);
});

test('una sola prueba con 5 %, en 20 builds: lo más probable es ver un rojo', () => {
  assert.equal(rounded(falseRedProbability(0.05, 20)), 64.2);
});

test('sin inestabilidad no hay rojos falsos', () => {
  assert.equal(falseRedProbability(0, 40), 0);
});
lockfile-report.test.tsDescargar lockfile-report.test.ts
// Lo que el apunte dice del árbol de dependencias del laboratorio, sobre el
// lockfile de la corrida guardada en esperado/.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { closure, loadLockfile, nameOf, report, requesters, resolveFrom } from './lockfile-report.ts';
import { HERE } from './tools.ts';

const lock = loadLockfile(resolve(HERE, 'esperado/package-lock.json'));
const summary = report(lock);

test('seis directas instalan 100 paquetes', () => {
  assert.equal(summary.direct.length, 6);
  assert.equal(summary.total, 100);
});

test('ESLint y typescript-eslint traen casi todo', () => {
  const brings = Object.fromEntries(summary.direct.map((d) => [d.name, d.brings]));
  assert.deepEqual(brings, {
    '@eslint/js': 0,
    '@types/node': 1,
    eslint: 76,
    'fast-check': 1,
    typescript: 0,
    'typescript-eslint': 26,
  });
});

test('las directas se pidieron con la versión exacta, y son las de la imagen', () => {
  const pkg = JSON.parse(readFileSync(resolve(HERE, 'esperado/package.json'), 'utf8')) as {
    devDependencies: Record<string, string>;
  };
  const dockerfile = readFileSync(resolve(HERE, '../../env/Dockerfile'), 'utf8');
  for (const [name, version] of Object.entries(pkg.devDependencies)) {
    assert.match(version, /^\d+\.\d+\.\d+$/, name);
    assert.ok(dockerfile.includes(`${name}@${version}`), `${name}@${version}`);
  }
});

test('todos llevan hash, vienen del registro y ninguno corre scripts al instalarse', () => {
  assert.deepEqual(summary.withoutIntegrity, []);
  assert.deepEqual(summary.hosts, ['registry.npmjs.org']);
  assert.deepEqual(summary.installScripts, []);
});

test('tres paquetes están en dos versiones: npm anida la segunda copia', () => {
  assert.deepEqual(summary.nested.map((n) => n.name), ['eslint-visitor-keys', 'hookified', 'ignore']);
  const nested = 'node_modules/@eslint-community/eslint-utils';
  const found = resolveFrom(lock, nested, 'eslint-visitor-keys');
  assert.equal(found, `${nested}/node_modules/eslint-visitor-keys`);
  assert.equal(lock.packages[found as string]?.version, '3.4.3');
  assert.equal(lock.packages[resolveFrom(lock, 'node_modules/eslint', 'eslint-visitor-keys') as string]?.version, '5.0.1');
});

test('minimatch: tres pedidos con rangos distintos, una sola copia', () => {
  assert.deepEqual(
    requesters(lock, 'minimatch').map((r) => r.range).sort(),
    ['^10.2.2', '^10.2.4', '^10.2.6'],
  );
  assert.equal(lock.packages['node_modules/minimatch']?.version, '10.2.6');
});

test('debug está en el árbol, en la versión 4.4.3', () => {
  assert.equal(lock.packages['node_modules/debug']?.version, '4.4.3');
});

test('closure no cuenta al propio paquete y sigue dependencias anidadas', () => {
  const eslint = closure(lock, 'node_modules/eslint');
  assert.ok(!eslint.has('node_modules/eslint'));
  assert.ok([...eslint].map(nameOf).includes('minimatch'));
});

test('nameOf entiende paquetes anidados y con scope', () => {
  assert.equal(nameOf('node_modules/a/node_modules/@b/c'), '@b/c');
});
revisar-workflow.test.tsDescargar revisar-workflow.test.ts
// Lo que debe marcar, y lo que no, el revisor de workflows.
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { parse } from 'yaml';
import { checkFile, checkWorkflow } from './revisar-workflow.ts';
import { HERE, run } from './tools.ts';

const errors = (path: string) =>
  checkFile(resolve(HERE, path))
    .filter((f) => f.level === 'error')
    .map((f) => f.rule);

const SHA = 'a'.repeat(40);
const GOOD = `
on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@${SHA}
      - uses: actions/setup-node@${SHA}
        with:
          node-version: 26
      - run: npm ci
      - run: echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
      - run: make check
      - run: npm audit --audit-level=high
      - run: npm audit signatures
`;

test('uno bien hecho pasa sin errores ni avisos', () => {
  assert.deepEqual(checkWorkflow(parse(GOOD)), []);
});

test('el primer intento del apunte: falta instalar y falta el PATH', () => {
  assert.deepEqual(errors('ejemplos/workflow-incompleto.yml'), ['npm-ci', 'path']);
});

test('el workflow peligroso cae en todas las reglas de la lista', () => {
  assert.deepEqual(new Set(errors('ejemplos/workflow-peligroso.yml')), new Set([
    'evento-pr', 'pull-request-target', 'permisos', 'sigue-igual', 'npm-install', 'npm-ci',
    'path', 'secret', 'tuberia', 'audit', 'fijar',
  ]));
});

test('una etiqueta de versión es un aviso; una rama, un error', () => {
  const findings = checkFile(resolve(HERE, 'ejemplos/workflow-peligroso.yml'));
  assert.ok(findings.some((f) => f.level === 'aviso' && f.message.includes('changed-files@v45')));
  assert.ok(findings.some((f) => f.level === 'error' && f.message.includes('checkout@main')));
});

test('make check con tubería pasa si el step declara shell: bash', () => {
  const piped = GOOD.replace('- run: make check', '- run: make check | tee check.log\n        shell: bash');
  assert.deepEqual(checkWorkflow(parse(piped)), []);
});

test('por consola, sale con 1 si hay errores', () => {
  const result = run('node', ['revisar-workflow.ts', 'ejemplos/workflow-incompleto.yml']);
  assert.equal(result.code, 1);
  assert.match(result.output, /2 errores, 2 avisos/);
});
semver-range.test.tsDescargar semver-range.test.ts
// La tabla del apunte, y una prueba diferencial contra el paquete semver, el
// que usa npm de verdad.
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { test } from 'node:test';
import fc from 'fast-check';
import { boundsOf, satisfies } from './semver-range.ts';

const semver = createRequire(import.meta.url)('semver') as {
  satisfies(version: string, range: string): boolean;
};

const CASES: [string, string, boolean][] = [
  ['8.72.1', '^8.72.1', true],
  ['8.73.0', '^8.72.1', true],
  ['8.99.9', '^8.72.1', true],
  ['8.72.0', '^8.72.1', false],
  ['9.0.0', '^8.72.1', false],
  ['8.72.9', '~8.72.1', true],
  ['8.73.0', '~8.72.1', false],
  ['8.72.1', '8.72.1', true],
  ['8.72.2', '8.72.1', false],
  ['0.23.9', '^0.23.5', true],
  ['0.24.0', '^0.23.5', false],
  ['0.0.3', '^0.0.3', true],
  ['0.0.4', '^0.0.3', false],
  // El rango con que se pide debug en el árbol de ESLint aceptaba la versión con
  // malware de septiembre de 2025.
  ['4.4.2', '^4.3.1', true],
];

for (const [version, range, expected] of CASES) {
  test(`${version} ${expected ? 'entra' : 'no entra'} en ${range}`, () => {
    assert.equal(satisfies(version, range), expected);
    assert.equal(semver.satisfies(version, range), expected);
  });
}

test('un rango que no entiende no acepta nada', () => {
  assert.equal(boundsOf('latest'), null);
  assert.equal(satisfies('1.0.0', '>=1.0.0'), false);
});

const small = fc.integer({ min: 0, max: 4 });
const version = fc.tuple(small, small, small).map((parts) => parts.join('.'));
const range = fc.tuple(fc.constantFrom('', '^', '~'), version).map(([operator, base]) => operator + base);

test('da lo mismo que el paquete semver', () => {
  fc.assert(
    fc.property(version, range, (v, r) => satisfies(v, r) === semver.satisfies(v, r)),
    { numRuns: 5000 },
  );
});
sugerencia.test.tsDescargar sugerencia.test.ts
// El material del paso 8 del laboratorio.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { HERE } from './tools.ts';

const text = readFileSync(resolve(HERE, 'sugerencia.md'), 'utf8');

test('se presenta como una ilustración escrita para la clase', () => {
  assert.match(text.split('\n')[0] ?? '', /^Ilustración escrita para la clase/);
});

test('trae exactamente tres npm install', () => {
  assert.equal([...text.matchAll(/^npm install \S+$/gm)].length, 3);
});

test('no dice cuál de los tres no existe', () => {
  assert.doesNotMatch(text, /\b404\b|inventad|no existe/i);
});

test('todo paquete que usa el código aparece en los npm install', () => {
  const installs = [...text.matchAll(/^npm install (\S+)$/gm)].map((m) => m[1]);
  const imports = [...text.matchAll(/^import .* from '([^.'][^']*)';$/gm)].map((m) => m[1]);
  assert.deepEqual([...imports].sort(), [...installs].sort());
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 12: integración continua y dependencias. Se usa dentro del contenedor
# del curso.
#
#   make              copia Kallpa en build/kallpa, para probar sin tocar la
#                     original (no descarga nada)
#   make test         revisa los tipos (tsc), corre ESLint y las pruebas
#                     (*.test.ts), que comprueban lo que dice el apunte. No usa
#                     la red: un build que depende de la red no es hermético
#   make tipos        un cambio que pasa las pruebas y no pasa tsc
#   make colision     dos ramas en verde cuyo merge queda en rojo
#   make zona         una prueba que pasa en Lima y falla en UTC
#   make reporte      lo que dice el lockfile de esperado/
#
# Estos usan el registro de npm, y sus resultados cambian con el tiempo. Dejan
# una copia de lo que imprimen en build/salidas/:
#
#   make arbol        instala las seis devDependencies y mira el árbol
#   make runner       npm ci y make check con el PATH de un runner
#   make falta        un paquete sin declarar, que aquí no se nota
#   make lockfile     npm ci cuando el lockfile no coincide o el hash es otro
#   make auditar      npm audit con cada opción, npm outdated y las firmas
#   make fuerza       lo que hace npm audit fix --force
#   make peer         lo que hace TypeScript 7 con typescript-eslint
#   make rangos       el mismo package.json, en dos fechas distintas
#   make sbom         la lista de todo lo instalado
#   make paquetes     cómo se revisa un paquete, y uno que no existe
#
#   make clean        borra build/
#
# Fuera del contenedor, en la terminal de su computadora:
#
#   sh runner-limpio.sh    make check en una imagen de Node sin las herramientas

KALLPA ?= ../../kallpa

.PHONY: all test tipos colision zona reporte arbol runner falta lockfile auditar fuerza peer rangos sbom paquetes clean

all: build/kallpa

build/kallpa:
	mkdir -p build
	cp -r $(KALLPA) $@
	rm -f $@/data/*.db $@/data/*.db-*

test:
	tsc -p .
	eslint .
	node --test "*.test.ts"
	@if [ -d solution ]; then node --test "solution/*.test.ts"; fi

tipos:
	@bash escenarios.sh tipos

colision:
	@bash escenarios.sh colision

zona:
	@bash escenarios.sh zona

reporte:
	@node lockfile-report.ts esperado/package-lock.json

arbol runner falta lockfile auditar fuerza peer rangos sbom paquetes:
	@mkdir -p build/salidas
	@bash registro.sh $@ 2>&1 | tee build/salidas/$@.txt

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-12",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": ["es2024"],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["*.ts", "ejemplos"],
  "exclude": ["build", "solution"]
}