semver a fondo: de un rango a una version
La gramatica completa de las versiones: precedencia, el operador caret y su comportamiento especial en la zona 0.x, la tilde, los rangos compuestos, las prereleases y el algoritmo exacto por el que un rango se resuelve a una version concreta que el lockfile congela.
Detrás de un inocente ^1.2.3 hay una gramática formal, un orden de precedencia riguroso y un algoritmo de resolución que decide, entre todas las versiones publicadas de un paquete, cuál acaba en tu node_modules. Entender semver no es memorizar que el caret deja subir la minor: es comprender por qué ^0.2.3 se comporta distinto de ^1.2.3, qué hace realmente una prerelease en el orden de versiones y cómo un rango flexible se colapsa en una versión exacta que el lockfile fija para siempre. Es la diferencia entre confiar en las actualizaciones y temerlas.
- Leer la estructura
MAJOR.MINOR.PATCHy aplicar las reglas de precedencia, incluidas las prereleases. - Dominar
^y~, con especial atención al comportamiento del caret en la zona0.x. - Componer rangos con límites, uniones y guiones, y saber qué versiones incluyen y cuáles no.
- Trazar el algoritmo por el que un rango se resuelve a una versión concreta y se fija en el lockfile.
Anatomía de una versión y su precedencia
Una versión semver son tres números —MAJOR, MINOR, PATCH— con una semántica de compatibilidad: MAJOR sube ante un cambio incompatible, MINOR ante una funcionalidad retrocompatible, PATCH ante una corrección retrocompatible. La precedencia se compara campo a campo de izquierda a derecha de forma numérica: 1.9.0 es anterior a 1.10.0, aunque nueve parezca mayor que diez si se leyera como texto. Ese detalle —comparación numérica, no lexicográfica— es la base de todo lo demás.
- MAJOR: rompe la API. El consumidor debe leer notas de migración.
- MINOR: añade sin romper. Actualizar debería ser seguro.
- PATCH: corrige sin añadir ni romper. Actualizar es casi obligatorio.
Existe un cuarto componente opcional, los metadatos de build, que se anexan con un +: 1.0.0+20260726 o 1.0.0+sha.a1b2c3. A diferencia de las prereleases, los metadatos de build se ignoran por completo al comparar precedencia —dos versiones que solo difieren tras el + se consideran iguales—, de modo que sirven para trazar de dónde salió un artefacto sin alterar su orden.
Conviene recordar que semver es un contrato social, no una ley que el registro imponga: nada impide técnicamente a un autor publicar un cambio incompatible disfrazado de simple patch. La comunidad lo respeta de forma abrumadora, y los tests de integración y los rangos prudentes existen precisamente para protegerte de las excepciones. Confía, pero verifica.
Los operadores de rango: ^, ~ y los límites
El caret ^ es el operador por defecto que instala pnpm: permite todos los cambios que no modifiquen el dígito distinto de cero situado más a la izquierda. Sobre versiones estables se traduce en “esta minor y las siguientes, sin llegar a la próxima major”. La tilde ~ es más conservadora: si fijas la minor, solo deja subir el patch.
^1.2.3 equivale a >=1.2.3 <2.0.0
~1.2.3 equivale a >=1.2.3 <1.3.0
~1.2 equivale a >=1.2.0 <1.3.0
1.2.x equivale a >=1.2.0 <1.3.0
* equivale a cualquier version
La sutileza que separa a quien entiende semver de quien lo recita es el comportamiento del caret en la zona 0.x, donde la especificación considera que cada número puede indicar un cambio incompatible porque la API aún no se estabilizó:
^1.2.3 equivale a >=1.2.3 <2.0.0 # sube minor y patch
^0.2.3 equivale a >=0.2.3 <0.3.0 # solo sube patch
^0.0.3 equivale a >=0.0.3 <0.0.4 # fija la version exacta
La mayoría de las librerías jóvenes viven en 0.x, y ahí ^0.3.1 no te da las mejoras de 0.4.0: se queda en la serie 0.3. Mucha gente cree que el caret siempre “sube minors” y se extraña de no recibir una actualización que sí existe. Es coherente con la especificación —en 0.x cada dígito puede romper— pero contraintuitivo. Si quieres seguir de cerca un paquete 0.x, sé explícito con el rango en lugar de confiar en el caret.
caret ^
Deja subir hasta la próxima major en versiones estables. En 0.x se vuelve conservador porque cada dígito puede romper.
tilde ~
Fija minor y solo deja subir el patch. La elección prudente cuando no confías en las minors de un paquete.
exacta
Sin operador, 1.2.3 significa exactamente esa versión. Máximo control, mínima recepción de correcciones.
compuesta
Espacios combinan con Y, || con O. >=1.2.0 <2 || >=3 acepta dos ventanas disjuntas.
Los rangos se componen: un espacio significa intersección (>=1.2.0 <1.5.0), el doble barra vertical || significa unión, el guion define un rango cerrado inclusivo (1.2.3 - 2.3.4), y las x-ranges (1.x, 1.*) abrevian ventanas completas. Todo se puede escribir como una combinación de los operadores de comparación >=, <=, >, < y la igualdad.
Traducciones que conviene tener grabadas:
1.2.3es exactamente esa versión, sin margen alguno.~1.2.3acepta parches de la serie1.2.^1.2.3acepta minors y parches por debajo de2.0.0.1.2.xacepta cualquier parche de1.2.>=1.2.0 <2es el equivalente explícito del caret sobre1.x.1.2.3 - 2.3.4es un rango cerrado, inclusivo por ambos extremos.*oxaceptan cualquier versión; casi nunca es lo que quieres en producción.
Cuando ejecutas pnpm add react, el operador que se escribe por defecto es el caret; puedes cambiar ese temperamento de una vez para todo el árbol con la opción save-prefix.
pnpm add react # escribe "react": "^19.0.0"
pnpm add -E react # exacto: "react": "19.0.0"
pnpm config set save-prefix '~' # cambia el operador por defecto a tilde
Prereleases: el territorio antes del estreno
Una prerelease se marca con un guion tras el patch: 1.0.0-beta.2. En el orden de precedencia, una prerelease es anterior a su versión final: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0. Los identificadores se comparan de izquierda a derecha; los numéricos por valor y los alfanuméricos por orden ASCII, y tener más campos rompe empates. La regla que más sorprende: un rango como >=1.2.3 no incluye 2.0.0-beta.1, porque las prereleases solo entran en juego cuando el propio rango nombra una prerelease en esa misma terna de números (o cuando se activa includePrerelease). Es un cortafuegos deliberado: no quieres que un ^1.0.0 te arrastre a una beta inestable de la siguiente major.
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta
1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11
1.0.0-rc.1 < 1.0.0
Fíjate en la segunda línea: beta.11 es posterior a beta.2 porque los identificadores numéricos se comparan por valor, no como texto —donde “11” sería anterior a “2”—. Este detalle es la causa de más de un canal de prereleases desordenado.
Las prereleases se distribuyen bajo dist-tags como next o beta, no bajo el latest por defecto, de modo que un pnpm add paquete normal jamás las arrastra: solo llegan a quien las pide de forma explícita con paquete@next. Es el mecanismo que separa el canal estable del experimental sin usar el número de versión para ello.
De un rango a una versión concreta
Aquí está el corazón del asunto. Un rango es una especificación; la versión instalada es el resultado de aplicar un algoritmo determinista sobre las versiones realmente publicadas:
flowchart TD A[Rango en package.json] --> B[Lista de versiones publicadas en el registro] B --> C[Filtra las que satisfacen el rango] C --> D[Elige la maxima de las que quedan] D --> E[Fija version exacta e integridad en el lockfile] E --> F[Reinstalar usa el lockfile, no vuelve a resolver]
El gestor consulta el registro, descarta las versiones que no satisfacen el rango y, de las supervivientes, elige la máxima —la política de highest satisfying version que comparten npm, pnpm y yarn—. Esa versión concreta, junto a su hash de integridad, se escribe en el lockfile. A partir de ahí, pnpm install no vuelve a resolver: replica exactamente lo que el lockfile dicta, lo que garantiza que tu máquina, la de tu compañera y el CI instalen bit a bit lo mismo. El rango solo se reevalúa cuando pides explícitamente actualizar.
Esa actualización tiene dos modos que conviene no confundir:
pnpm upreevalúa los rangos existentes y sube a la máxima que cada rango permite, sin tocar elpackage.json.pnpm up --latestignora los rangos, salta a la última versión de cada dependencia y reescribe los rangos enpackage.json; es un cambio mayor que exige revisión.pnpm outdatedmuestra la distancia entre lo instalado y lo disponible sin cambiar nada: el paso previo sensato a cualquier actualización.
En CI, esta disciplina se blinda con pnpm install --frozen-lockfile: la instalación falla si el lockfile no concuerda con el package.json en vez de actualizarlo en silencio. Es el pestillo que convierte debería ser reproducible en es reproducible o no compila.
Un último matiz sobre la resolución: cuando varias dependencias piden el mismo paquete con rangos distintos, el resolutor busca una única versión que satisfaga a todas y la comparte —deduplicación—, e instala copias separadas solo cuando los rangos son incompatibles. Por eso los rangos innecesariamente estrechos fragmentan el árbol y engordan la instalación: cada restricción de más es una oportunidad menos de compartir.
Actualizar a mano no escala. Herramientas como Renovate o Dependabot vigilan el registro, abren pull requests cuando una dependencia publica una versión nueva dentro de tu rango, y ejecutan tu CI sobre el cambio antes de que lo mires. Convierten la actualización en un flujo continuo de cambios pequeños y revisables, en lugar de un salto anual doloroso. El semver que has aprendido es justo el lenguaje con el que configuras hasta dónde les permites llegar solos.
Que npm, pnpm y yarn elijan la versión máxima dentro del rango no es la única estrategia posible, y verla como una decisión de diseño ayuda a entenderla. El ecosistema de Go adoptó lo contrario: minimal version selection, donde el resolutor elige la versión mínima que satisface las restricciones de todo el grafo. La filosofía de JavaScript apuesta por recibir parches y mejoras cuanto antes, al precio de que una minor recién publicada pueda colarse sin que lo pidas; la de Go apuesta por la reproducibilidad máxima y los cambios solo explícitos, al precio de quedarse atrás en correcciones. Ninguna es universalmente superior: son puntos distintos en el eje frescura frente a estabilidad, el mismo que gobierna toda la ingeniería del build. Conocer ambas te libera de creer que “así se hace” y te deja elegir tus rangos con criterio.
El malentendido más caro sobre semver es creer que el rango que escribes en package.json es lo que se instala. No lo es. El rango es una declaración de intenciones —“acepto cualquier versión compatible dentro de estos límites”— y el lockfile es el hecho consumado: la versión exacta que se resolvió la última vez que alguien actualizó. Esta separación entre intención y hecho es lo que hace reproducibles las instalaciones sin renunciar a recibir correcciones: el rango deja la puerta abierta a los parches, y el lockfile impide que esa puerta se abra sola en el peor momento, como a mitad de un despliegue. De ahí se derivan dos disciplinas que todo equipo maduro interioriza. La primera: el lockfile se versiona en git siempre, sin excepciones, porque es la única garantía de que “en mi máquina funciona” signifique algo. La segunda: actualizar es un acto deliberado, con su commit, su revisión y su CI, no un efecto colateral de instalar. Y una advertencia final de diseño: el caret es cómodo pero confiado; supone que cada autor respeta semver a rajatabla, cosa que el mundo real desmiente a menudo. Por eso la elección entre ^, ~ y versión exacta no es estilística, sino una medida calibrada de cuánta confianza depositas en la disciplina de versionado de cada dependencia.
- Dado el rango
^0.5.2y las versiones publicadas0.5.2,0.5.9,0.6.0y0.6.1, determina cuál se instalaría y explica por qué el caret se detiene donde se detiene. - Comprueba si
>=1.4.0incluye2.0.0-rc.1y articula la regla de prereleases que lo decide. - En un proyecto tuyo, cambia un
^por un~en una dependencia ruidosa, ejecutapnpm upy observa qué versión se fija en el lockfile. - Abre el lockfile, localiza una dependencia y contrasta el rango declarado con la versión exacta resuelta; entiende esa pareja como intención frente a hecho.