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:
{
"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-reacto@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 connpm 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.jsde este proyecto usanimport/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 conrequire()y exportaban conmodule.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.scripts— atajos 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í:
"dependencies": {
"date-fns": "^4.1.0"
}devDependencies — solo en desarrollo. Vite no lo necesita el navegador del usuario, solo tu máquina:
"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:
{
"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í:
"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:
"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>):
# 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 lintTres 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.
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):
"ci": "pnpm run lint && pnpm run build && pnpm run test"Pasar argumentos con --. Los argumentos tras -- se reenvían al comando del script:
pnpm build -- --mode staging
# equivale a: vite build --mode stagingLos 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.xa2.0.0. - MINOR sube cuando añades funcionalidad nueva compatible hacia atrás: puedes actualizar sin romper nada existente. Pasas de
1.2.xa1.3.0. - PATCH sube con arreglos de bugs compatibles: no hay novedades, solo correcciones. Pasas de
1.2.3a1.2.4.
En el package.json no fijas versiones exactas (eso lo hace el lockfile): declaras rangos con símbolos:
| Rango | Significado | Ejemplo: admite |
|---|---|---|
1.2.5 | Exacto | Solo 1.2.5 |
^1.2.5 | Compatible (bloquea major) | >= 1.2.5 y < 2.0.0 |
~1.2.5 | Conservador (bloquea minor) | >= 1.2.5 y < 1.3.0 |
>=1.2.5 | Mayor o igual | 1.2.5, 1.3.0, 2.0.0… |
* | Cualquiera | Todo |
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:
^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
Paso 2: Tilde y ^0.x
- Todo lo del tier OK
- satisface('~1.2.5', '1.2.5') devuelve true
- satisface('~1.2.5', '1.3.0') devuelve false
- satisface('^0.2.0', '0.2.5') devuelve true
- satisface('^0.2.0', '0.3.0') devuelve false
Paso 3: Intervalo limpio
- Todo lo del tier Mejor
- satisface() se reduce a una comparación del intervalo [mínimo, techo)
- No hay lógica de comparación duplicada: una sola función intervalo() + una sola función comparar()
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.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL MEJOR — "cubre los rangos reales"
//
// Por qué supera a OK:
// Añades tilde (~) y el caso especial ^0.x: ahora cubres los rangos que de
// verdad aparecen en un package.json. ^0.x es diferente porque con major 0
// cualquier cambio de minor es potencialmente rompedor; npm lo trata como
// si el minor fuera el major.
//
// Qué falta para subir:
// Las tres ramas del if duplican la lógica de "calcular el techo". Excelente
// lo resuelve extrayendo esa lógica a una función reutilizable.
// ════════════════════════════════════════════════════════════════════════════
// 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]
return version.split('.').map(Number);
}
// ¿version es mayor o igual que base? Compara número a número.
function mayorOIgual(version, base) {
// [major, minor, patch] de la versión
const v = partes(version);
// [major, minor, patch] del mínimo
const b = partes(base);
for (let i = 0; i < 3; i++) {
// mayor en este nivel: cumple
if (v[i] > b[i]) return true;
// menor en este nivel: no cumple
if (v[i] < b[i]) return false;
}
// versiones iguales: cumple >=
return true;
}
// Calcula el techo de un rango caret (^), respetando el caso especial ^0.x.
// Con major >= 1: el techo es el major siguiente (^1.2.3 -> <2.0.0).
// Con major 0 y minor > 0: el minor cuenta como ruptura (^0.2.3 -> <0.3.0).
// Con major 0 y minor 0: solo admite ese patch (^0.0.3 -> <0.0.4).
function techoCaret(base) {
const p = partes(base);
// ^1.2.3 -> <2.0.0
if (p[0] > 0) return (p[0] + 1) + '.0.0';
// ^0.2.3 -> <0.3.0
if (p[1] > 0) return '0.' + (p[1] + 1) + '.0';
// ^0.0.3 -> <0.0.4
return '0.0.' + (p[2] + 1);
}
// Calcula el techo de un rango tilde (~): bloquea el minor.
// ~1.2.3 admite >= 1.2.3 y < 1.3.0 (solo patches del mismo minor).
function techoTilde(base) {
const p = partes(base);
// ~1.2.3 -> <1.3.0
return p[0] + '.' + (p[1] + 1) + '.0';
}
// Mejor: cubre exacto, caret (con ^0.x) y tilde (~).
function satisface(rango, version) {
// caret: compatible hacia adelante
if (rango[0] === '^') {
// quita el ^ -> "1.2.5" o "0.2.3"
const base = rango.slice(1);
return mayorOIgual(version, base) && !mayorOIgual(version, techoCaret(base));
}
// tilde: solo patches del mismo minor
if (rango[0] === '~') {
// quita el ~ -> "1.2.5"
const base = rango.slice(1);
return mayorOIgual(version, base) && !mayorOIgual(version, techoTilde(base));
}
// exacto: sin símbolo, solo esa versión
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 -> 1.2.5 (no 1.3.0)
probar('~1.2.5'); Por qué es mejor que el anterior
- Añades tilde (~): bloquea el minor y solo deja pasar patches. Muchos equipos la usan cuando quieren control extra sobre actualizaciones de una librería crítica.
- El caso especial ^0.x es la trampa más frecuente de semver: con major 0 el minor actúa como major porque la API aún no es estable. Sin este caso, ^0.2.0 admitiría 0.3.0 por error.
- Ahora cubres los rangos que de verdad aparecen en un package.json. Pero si miras las tres ramas del if, todas calculan un techo: hay duplicación que el nivel Excelente elimina.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL EXCELENTE — "clean code"
//
// Por qué supera a Mejor:
// Ves que TODO rango es un intervalo [mínimo, techo): incluido el mínimo,
// excluido el techo. Esa observación permite una sola función intervalo()
// que calcula el par [min, techo] según el operador, y satisface queda en
// una línea: ¿la versión cae dentro de ese intervalo? Sin duplicar la
// lógica de comparación por cada operador (^, ~, exacto).
// ════════════════════════════════════════════════════════════════════════════
// 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'];
// Tres números de una versión: "1.2.3" -> [1, 2, 3].
function partes(version) {
// "1.2.3" -> ["1","2","3"] -> [1, 2, 3]
return version.split('.').map(Number);
}
// Compara dos versiones número a número: -1 si a<b, 0 si iguales, 1 si a>b.
function comparar(a, b) {
// [major, minor, patch] de a
const pa = partes(a);
// [major, minor, patch] de b
const pb = partes(b);
for (let i = 0; i < 3; i++) {
// el primero que difiere decide
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
}
// todos iguales
return 0;
}
// Todo rango se reduce a un intervalo [minimo, techo): incluido el mínimo, excluido
// el techo. Calcular ese par es lo ÚNICO que cambia entre ^, ~ y exacto.
function intervalo(rango) {
// detecta el operador
const op = rango[0] === '^' || rango[0] === '~' ? rango[0] : '=';
// quita el ^ o ~ si lo hay
const base = op === '=' ? rango : rango.slice(1);
// [major, minor, patch] del mínimo
const p = partes(base);
// bloquea minor: ~1.2.3 -> <1.3.0
if (op === '~') return [base, p[0] + '.' + (p[1] + 1) + '.0'];
// solo esa: 1.2.3 -> <1.2.4
if (op === '=') return [base, p[0] + '.' + p[1] + '.' + (p[2] + 1)];
// caret ^1.x -> <2.0.0
if (p[0] > 0) return [base, (p[0] + 1) + '.0.0'];
// caret ^0.x -> <0.3.0
if (p[1] > 0) return [base, '0.' + (p[1] + 1) + '.0'];
// caret ^0.0.x -> <0.0.4
return [base, '0.0.' + (p[2] + 1)];
}
// ¿version cae en el intervalo del rango? Una sola línea gracias a intervalo().
function satisface(rango, version) {
// [minimo, techo]
const limites = intervalo(rango);
// version >= minimo
return comparar(version, limites[0]) >= 0
// version < techo
&& comparar(version, limites[1]) < 0;
}
// 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 -> 1.2.5 (no 1.3.0)
probar('~1.2.5'); Por qué es mejor que el anterior
- Ves el patrón: TODO rango —exacto, caret, tilde— es un intervalo [mínimo, techo). Lo único que cambia entre operadores es cómo se calcula ese par. Eso lleva a extraer intervalo().
- Con intervalo() calculando el par, satisface queda en una sola línea: ¿version >= mínimo y < techo? La lógica de comparación ya no se repite por cada operador. DRY real.
- Es el clean code que el curso predica: un concepto, una función. Si mañana semver añade un cuarto operador, solo tocas intervalo(); satisface no cambia.