learning-front

Nivel 4 · Tooling profesional: el entorno de un proyecto serio

package.json a fondo y semver

El manifiesto del proyecto pieza a pieza —scripts, tipos de dependencia, engines— y el lenguaje de versiones (semver): caret, tilde y exacto.

El manifiesto, pieza a pieza#

Ya conoces el package.json de vista: lo creaste en el nivel 3 con npm init o pnpm init. Ahora lo lees de arriba a abajo, campo a campo, para entender qué hace cada línea.

Este es el package.json completo del Team Builder:

json
{
  "name": "@teambuilder/app",
  "version": "1.0.0",
  "description": "Gestor de equipo para Overwatch",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.0.0",
    "pnpm": ">=11.0.0"
  },
  "packageManager": "pnpm@11.7.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint src",
    "prebuild": "pnpm run lint"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "date-fns": "^4.1.0"
  },
  "devDependencies": {
    "vite": "^8.0.0",
    "@vitejs/plugin-react": "^6.0.0",
    "eslint": "^10.0.0",
    "vitest": "^4.0.0"
  }
}

Cada campo tiene su razón de ser:

  • name — el identificador del paquete. El prefijo @teambuilder/ es un scope: una manera de agrupar paquetes bajo el nombre de una organización. En proyectos de empresa privados el scope es solo nomenclatura de agrupación, no implica publicar nada en npm; lo verás en paquetes externos como @vitejs/plugin-react o @types/node.
  • version — la versión actual del proyecto, en formato semver (MAJOR.MINOR.PATCH). Lo veremos en detalle más adelante.
  • description — texto libre; lo muestra npm cuando publicas el paquete o buscas con npm search.
  • private: true — impide publicar este paquete en el registro de npm por accidente. En proyectos de aplicación (no librerías) siempre va aquí.
  • type: "module" — indica a Node que los ficheros .js de este proyecto usan import/export (ESM, el estándar moderno que ya usas desde el nivel 3). Sin este campo, Node asume el sistema de módulos que usaba históricamente, llamado CommonJS, donde los ficheros importaban con require() y exportaban con module.exports; hoy ese sistema es legado y no lo necesitas aprender para trabajar en frontend.
  • engines — declara qué versiones de Node y del gestor son compatibles. No bloquea la instalación por defecto, pero herramientas de CI y gestores como pnpm lo leen y avisan si la versión no encaja.
  • packageManager — le dice a Corepack qué gestor y versión exacta usar para este proyecto. Así todo el equipo trabaja con el mismo pnpm sin instalarlo a mano.
  • scriptsatajos de terminal; los vemos en la sección siguiente.
  • dependencies — paquetes que la app necesita en producción: acaban en el bundle que descarga el usuario.
  • devDependencies — paquetes que solo usas durante el desarrollo: el bundler, el linter, el framework de tests. No llegan al bundle del usuario.

Cuatro tipos de dependencia#

dependencies y devDependencies ya los conoces. Hay dos más que aparecen en proyectos reales:

dependencies — en producción. Si tu app muestra fechas formateadas, date-fns va aquí:

json
"dependencies": {
  "date-fns": "^4.1.0"
}

devDependencies — solo en desarrollo. Vite no lo necesita el navegador del usuario, solo tu máquina:

json
"devDependencies": {
  "vite": "^8.0.0"
}

peerDependencies — para plugins. Un plugin no instala su propia copia de la librería que extiende: declara que espera que el proyecto que lo usa ya la tenga. Ejemplo: el plugin oficial de React para Vite declara react como peer, para compartir la misma copia de React que la app:

json
{
  "name": "@vitejs/plugin-react",
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  }
}

Cuando instalas @vitejs/plugin-react en tu proyecto, el plugin no instala React de nuevo: usa el que ya tienes. Si no lo tienes, el gestor te avisa. Así nunca acabas con dos copias de React conviviendo y causando errores extraños.

optionalDependencies — dependencias que el paquete usará si están disponibles, pero cuya ausencia no es un error. Poco frecuentes; el caso más habitual en el ecosistema Node es fsevents, un módulo de vigilancia de ficheros que solo existe en macOS y que herramientas como Vite aprovechan cuando están ahí:

json
"optionalDependencies": {
  "fsevents": "^2.3.3"
}

No necesitas crearla tú; la encontrarás en el package.json de dependencias que instales.

Scripts a fondo#

El campo scripts es un mapa de nombre a comando de terminal:

json
"scripts": {
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview",
  "lint": "eslint src",
  "prebuild": "pnpm run lint"
}

Los ejecutas con npm run <nombre> (o pnpm <nombre> o yarn <nombre>):

shell
# arranca el servidor de desarrollo
pnpm dev
# genera la versión de producción en dist/
pnpm build
# analiza el código buscando errores
pnpm lint

Tres mecanismos útiles que no son obvios a primera vista:

Hooks pre y post. Si declaras un script llamado pre<nombre>, npm lo ejecuta automáticamente antes de <nombre>. Y post<nombre>, después. En el ejemplo, prebuild corre el linter antes de cada build: si el linter falla, el build se cancela.

shell
pnpm build
# -> ejecuta primero "pnpm run lint"
# -> si lint falla, build no arranca
# -> si lint pasa, ejecuta "vite build"

Encadenar con &&. Puedes combinar varios comandos en un solo script. El segundo solo corre si el primero tuvo éxito (salida 0):

json
"ci": "pnpm run lint && pnpm run build && pnpm run test"

Pasar argumentos con --. Los argumentos tras -- se reenvían al comando del script:

shell
pnpm build -- --mode staging
# equivale a: vite build --mode staging

Los scripts son el pegamento del flujo de trabajo: en vez de recordar el comando largo de cada herramienta, defines nombres cortos y consistentes para todo el equipo.

semver: el lenguaje de las versiones#

Semver (Semantic Versioning) es el acuerdo de la comunidad sobre qué significa cambiar un número de versión. El formato es MAJOR.MINOR.PATCH:

  • MAJOR sube cuando hay cambios rompedores: algo que funcionaba deja de funcionar con la nueva versión. Pasas de 1.x a 2.0.0.
  • MINOR sube cuando añades funcionalidad nueva compatible hacia atrás: puedes actualizar sin romper nada existente. Pasas de 1.2.x a 1.3.0.
  • PATCH sube con arreglos de bugs compatibles: no hay novedades, solo correcciones. Pasas de 1.2.3 a 1.2.4.

En el package.json no fijas versiones exactas (eso lo hace el lockfile): declaras rangos con símbolos:

RangoSignificadoEjemplo: admite
1.2.5ExactoSolo 1.2.5
^1.2.5Compatible (bloquea major)>= 1.2.5 y < 2.0.0
~1.2.5Conservador (bloquea minor)>= 1.2.5 y < 1.3.0
>=1.2.5Mayor o igual1.2.5, 1.3.0, 2.0.0
*CualquieraTodo

El caret (^) es el predeterminado cuando instalas con npm install o pnpm add. La lógica es: una nueva versión compatible no debería romperte nada, así que puedes recibirla automáticamente.

El caso especial ^0.x.

Con major 0, semver dice que la API todavía no es estable: cualquier cambio de minor puede ser rompedor. Por eso ^0.2.0 no admite hasta 1.0.0, sino solo hasta 0.3.0:

texto
^0.2.0  admite: >= 0.2.0 y < 0.3.0   (el minor actúa como major)
^0.0.3  admite: >= 0.0.3 y < 0.0.4   (el patch actúa como major)

Muchas librerías en desarrollo activo publican bajo 0.x precisamente para avisar de que aún pueden cambiar su API.

Relación con el lockfile.

El package.json declara intenciones (rangos). El lockfile (pnpm-lock.yaml o package-lock.json) resuelve esos rangos a versiones concretas y las fija para todo el equipo. Cuando alguien hace pnpm install en otro ordenador, obtiene exactamente las mismas versiones, no “algo que cumple el rango”.

Conclusión práctica: los rangos del package.json son para humanos; las versiones exactas del lockfile son para las máquinas.

Comprueba lo que sabes#

Pregunta 1 de 5

Tu package.json declara `"@teambuilder/heroes": "^3.6.0"`. ¿Qué versiones admite ese rango?

Tu turno#

Ahora que conoces semver por dentro, impleméntalo: escribe satisface(rango, version) para que el harness de probar() imprima las versiones correctas. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en cómo el nivel Excelente elimina la duplicación extrayendo un único concepto: todo rango es un intervalo.

Ejercicio · en esta página

Validador de semver

Tu equipo publica `@teambuilder/heroes` y quieres saber qué versiones ya publicadas cumplen el rango que pondrías en tu package.json. Implementa `satisface(rango, version)` para que `probar(rango)` imprima las versiones correctas.

Paso 1: Exacto y caret básico

  • satisface('1.2.5', '1.2.5') devuelve true
  • satisface('1.2.5', '1.3.0') devuelve false
  • satisface('^1.2.5', '1.3.0') devuelve true
  • satisface('^1.2.5', '2.0.0') devuelve false
Ver soluciones
// ════════════════════════════════════════════════════════════════════════════
// NIVEL OK — "que funcione"
//
// Por qué es el suelo:
//   Resuelves el caso más común: comparar versiones exactas y aplicar el caret
//   (^) cuando el major es >= 1. Es la lógica correcta para el 90% de los rangos
//   que verás en un package.json de empresa.
//
// Qué falta para subir:
//   No cubre tilde (~) ni el caso especial ^0.x, que se comporta distinto.
// ════════════════════════════════════════════════════════════════════════════

// Versiones publicadas de @teambuilder/heroes (de la más vieja a la más nueva):
const publicadas = ['1.2.0', '1.2.5', '1.3.0', '1.9.9', '2.0.0'];

// Parte una versión "1.2.3" en sus tres números: [1, 2, 3].
function partes(version) {
  // "1.2.3" -> ["1","2","3"] -> [1,2,3]
  return version.split('.').map(Number);
}

// ¿version es mayor o igual que base? Compara major, luego minor, luego patch.
function mayorOIgual(version, base) {
  // [major, minor, patch] de la versión a comprobar
  const v = partes(version);
  // [major, minor, patch] del mínimo
  const b = partes(base);
  for (let i = 0; i < 3; i++) {
    // ya es mayor en este nivel: no hace falta seguir
    if (v[i] > b[i]) return true;
    // ya es menor: descartada
    if (v[i] < b[i]) return false;
  }
  // todos los números iguales: es la misma versión (cumple >=)
  return true;
}

// OK: cubre el caso común -> exacto y caret (^) con major >= 1.
function satisface(rango, version) {
  // caret: compatible hacia adelante, bloquea el major
  if (rango[0] === '^') {
    // quita el ^ para quedarnos con "1.2.5"
    const base = rango.slice(1);
    // el major del rango, p.ej. 1
    const major = partes(base)[0];
    // primer major siguiente, EXCLUIDO: "2.0.0"
    const techo = (major + 1) + '.0.0';
    return mayorOIgual(version, base) && !mayorOIgual(version, techo);
    //     ^ version >= base               ^ version < techo
  }
  // exacto: sin símbolo, tiene que ser esa versión y solo esa
  return version === rango;
}

// No toques esto: prueba tu función e imprime qué versiones admite cada rango.
function probar(rango) {
  const cumplen = publicadas.filter(function (v) { return satisface(rango, v); });
  console.log(rango + ' admite: ' + (cumplen.length ? cumplen.join(', ') : '(ninguna)'));
}

// exacto -> solo 1.2.5
probar('1.2.5');
// caret  -> 1.2.5, 1.3.0, 1.9.9 (no 2.0.0)
probar('^1.2.5');
// tilde  -> (ninguna): aún no implementado en este tier
probar('~1.2.5');

Por qué este nivel

  • La lógica correcta es el suelo: comparas versiones número a número y aplicas la regla del caret. Es lo que hace el 90% de un package.json real.
  • Cubre el caso más común: exacto (sin símbolo) y caret con major >= 1. Esos dos rangos son la mayoría de las dependencias que instalas con pnpm add.
  • Aún quedan fuera la tilde (~) y el caso especial ^0.x, pero el mecanismo central ya es correcto. Subir al siguiente nivel es ampliar, no reescribir.