wandres.dev
EL COMPILADOR AMIGO · errores que enseñan

Versionado semántico forzado: cuando el número de versión deja de ser una opinión

En casi todos los ecosistemas el versionado semántico es una convención que cada autor aplica según su criterio, su prisa y su memoria, y el resultado es que el número de versión no significa nada verificable: una actualización de parche puede romper una compilación entera y nadie tiene forma de saberlo antes de que ocurra. Esta lección estudia la respuesta de Elm, que consiste en dejar de pedir la promesa y calcularla. Se explica cómo el compilador extrae la interfaz pública completa de un paquete a partir de sus tipos, cómo elm diff compara dos interfaces y clasifica cada cambio en adición, eliminación o modificación, y cómo elm bump y elm publish convierten esa clasificación en el número de versión obligatorio, rechazando cualquier intento de publicar una ruptura disfrazada de arreglo. Después se analizan las condiciones del lenguaje que hacen computable esa comparación y que faltan en la mayoría de los ecosistemas, se delimita con precisión la clase de rupturas que el mecanismo no puede ver, y se examinan los herederos parciales que han aparecido después en otras comunidades.

⏱ 18 min

El versionado semántico es una de las convenciones más citadas y menos cumplidas de la industria. La regla es célebre y sencilla: el primer número cambia cuando se rompe la compatibilidad, el segundo cuando se añade funcionalidad compatible y el tercero cuando solo se corrigen fallos. Todo el mundo la conoce, todo el mundo dice seguirla y todo el mundo ha sufrido una actualización de tercer número que destrozó una construcción que llevaba meses funcionando. La razón del incumplimiento no es la mala fe sino la estructura del asunto: el número lo elige la persona que publica, en el momento de publicar, basándose en su recuerdo de los cambios que ha hecho y en su juicio sobre si alguno rompe algo. Es una promesa emitida por quien no paga el coste de incumplirla y que nadie comprueba. Elm hizo con este problema exactamente lo mismo que había hecho con los errores en tiempo de ejecución: en vez de pedir más disciplina, eliminó la posibilidad de fallar. El número de versión de un paquete de Elm no lo escribe su autor, lo calcula una herramienta comparando la interfaz pública nueva con la publicada, y el registro se niega a aceptar cualquier otro.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el versionado semántico como convención voluntaria produce números de versión no verificables.
  • Leer la salida de elm diff e interpretar la clasificación de cada cambio en adición, eliminación y modificación.
  • Describir el flujo de publicación completo y el punto exacto en que el registro rechaza una versión mal numerada.
  • Delimitar la clase de rupturas que la comparación de interfaces no puede detectar y explicar por qué.

El número de versión como promesa incumplible

Conviene ver el problema como un asunto de incentivos y de información antes que de disciplina. Quien publica un paquete tiene información parcial sobre lo que rompe: sabe qué ha tocado, pero no siempre sabe qué de lo que ha tocado formaba parte de la superficie pública, ni qué usos remotos dependen de un detalle que él consideraba interno. Tiene además un incentivo suave hacia el número pequeño, porque una ruptura obliga a escribir notas de migración y desalienta la actualización. Y tiene un coste cero por equivocarse, porque el fallo lo sufren otros en máquinas que él no ve. Con esos tres ingredientes, la única sorpresa es que la convención funcione tan a menudo.

La consecuencia se paga en la otra punta. Como nadie puede confiar en el número, los consumidores fijan versiones exactas, dejan de actualizar, acumulan deuda y terminan haciendo saltos enormes en los que se rompe todo a la vez. El mecanismo pensado para permitir actualizaciones seguras acaba produciendo ecosistemas congelados, y ese resultado no es culpa de nadie en particular: es lo que ocurre cuando una garantía depende de un juicio humano repetido miles de veces.

Hay además un problema de definición que agrava el anterior y que casi nunca se nombra. Para decidir si un cambio rompe la compatibilidad hace falta saber qué era exactamente lo prometido, y en la mayoría de los ecosistemas esa frontera no está escrita en ninguna parte: se distingue entre lo público y lo interno por convención tipográfica, por documentación o por costumbre, y en cuanto un consumidor depende de algo que el autor consideraba privado, el desacuerdo sobre si hubo ruptura es irresoluble porque las dos partes tienen razón. Sin una frontera declarada y comprobable, la pregunta de si esto rompe algo no tiene respuesta objetiva, y por tanto tampoco puede tenerla el número de versión.

La interfaz pública como valor comparable

La idea que resuelve el problema es que la interfaz pública de un paquete de Elm es un dato: la lista de módulos expuestos, y dentro de cada uno la lista de tipos, alias y valores con sus firmas completas. El compilador ya conoce ese dato porque lo necesita para compilar, y por tanto puede escribirlo, guardarlo junto a la versión publicada y compararlo después con el de la versión nueva. Comparar dos listas de firmas es una operación mecánica cuyo resultado se clasifica sin ambigüedad en tres categorías, y cada categoría determina qué número tiene que subir.

# Comparar la version local con la publicada
elm diff

# Comparar dos versiones publicadas de un paquete ajeno
elm diff elm/http 1.0.0 2.0.0
This is a MAJOR change.

---- MODULOS ELIMINADOS - MAJOR ----

    Http.Progress


---- Http - MAJOR ----

    Added:
        type Body
        header : String -> String -> Header

    Removed:
        type alias Response
        send : Request a -> Cmd msg

    Changed:
      - get : String -> Decoder a -> Request a
      + get : { url : String, expect : Expect msg } -> Cmd msg

Añadir es menor

Un valor o un tipo nuevo no rompe a nadie: ningún programa existente lo usaba. Sube el segundo número y la actualización es segura.

Quitar es mayor

Eliminar un módulo, un tipo o una función deja sin compilar a cualquiera que la usara. Sube el primer número, sin excepciones.

🔀

Cambiar es mayor

Modificar una firma equivale a quitar la antigua y añadir otra. El comparador lo ve como ruptura aunque el nombre siga siendo el mismo.

🟰

Nada es parche

Si la interfaz no ha cambiado en absoluto, el cambio es interno y solo sube el tercer número. Es la única forma de publicar un parche.

Publicar: el registro decide tu número

El flujo completo es corto y no admite atajos. El autor pide a la herramienta que calcule la versión siguiente, la herramienta compara la interfaz local con la última publicada, clasifica el cambio y escribe el número resultante en el archivo de dependencias. Cuando después se intenta publicar, el registro repite la comprobación por su cuenta y rechaza el envío si el número no coincide con el que le corresponde. No hay una opción para forzarlo, no hay un argumento que salte la validación y no hay un modo experto.

# La herramienta calcula la version, no la eliges tu
elm bump
# Compared to the most recent version on the package website (1.0.0),
# this is a MINOR change. The new version is 1.1.0.

# Publicar: el registro vuelve a comprobarlo todo
elm publish

El registro comprueba además otras cosas en el mismo paso, y el conjunto explica por qué el catálogo de Elm tiene una consistencia poco frecuente. Todo paquete debe empezar en la primera versión estable, de modo que no existe el limbo de las versiones cero donde la convención declara que nada garantiza nada. Toda función expuesta debe llevar documentación, y la documentación se valida al publicar en lugar de generarse a posteriori. Las restricciones de dependencia se expresan siempre como un intervalo con extremo inferior incluido y superior excluido, lo cual permite que el resolutor razone sobre ellas sin heurísticas. Y ningún paquete puede incrustar código JavaScript arbitrario, lo cual mantiene el análisis completo y evita que una dependencia introduzca comportamientos que el sistema de tipos no ve.

flowchart LR
A[Interfaz publicada] --> C[Comparador]
B[Interfaz local] --> C
C --> M[Solo adiciones: menor]
C --> X[Eliminaciones o cambios: mayor]
C --> P[Sin cambios: parche]
M --> R[Registro acepta]
X --> R
P --> R
C --> N[Numero incorrecto: rechazo]
style C fill:#cba6f7,color:#11111b
style R fill:#a6e3a1,color:#11111b
style N fill:#f38ba8,color:#11111b
ℹ️
Por qué esto no se puede copiar tal cual en otros ecosistemas

La comparación automática de interfaces exige que la interfaz sea computable, y esa condición es mucho más exigente de lo que parece. Requiere que la superficie pública esté declarada de forma explícita, que los tipos describan por completo lo que una función acepta y devuelve, que no exista reflexión capaz de alcanzar lo no expuesto, que no haya despacho dinámico ni modificación de objetos ajenos en tiempo de ejecución y que ningún paquete pueda ejecutar código fuera del análisis. Un ecosistema donde cualquier consumidor puede alcanzar un detalle interno y depender de él no tiene una interfaz pública en sentido estricto, y por tanto no hay nada que comparar. Por eso lo que ha aparecido en otras comunidades son aproximaciones voluntarias que revisan la superficie declarada y avisan, y no un guardián en el punto de publicación: en Rust, en Go, en Java y en la plataforma de Microsoft existen comprobadores de compatibilidad de interfaz muy capaces, pero se ejecutan porque alguien decide ejecutarlos.

Hay un efecto secundario de este régimen que conviene señalar porque cambia la forma de diseñar bibliotecas. Como exponer algo es un compromiso que solo se puede deshacer subiendo el primer número, el autor aprende muy deprisa a exponer lo mínimo, y en particular aprende a preferir los tipos opacos, cuyos constructores no se publican, sobre los tipos abiertos. Un tipo cuyos constructores están expuestos convierte cada uno de ellos en parte del contrato y hace que añadir un caso interno sea una ruptura; un tipo opaco acompañado de funciones de construcción y de consulta permite reorganizar la representación entera sin que la interfaz se mueva. La herramienta no obliga a ese estilo, pero lo recompensa tan claramente que el catálogo entero converge hacia él.

Lo que la comparación no puede ver

La delimitación honesta de la garantía es tan importante como la garantía misma. El comparador ve tipos, no significados, y de ahí se sigue una clase entera de rupturas que pasan el filtro sin despeinarse. Una función que conserva su firma exacta y cambia de comportamiento —que empieza a ordenar al revés, que redondea de otro modo, que devuelve el error en vez de un valor por defecto, que pasa a distinguir mayúsculas— se publica como parche con toda legitimidad formal y rompe a sus usuarios igual que una eliminación. Un cambio en el formato de los datos que un paquete envía por un puerto tampoco se ve. Y una degradación seria del rendimiento es invisible por completo.

Hay también un límite en la dirección contraria, menos grave pero incómodo en la práctica: el comparador es estricto y no entiende de intenciones, así que clasifica como ruptura cambios que en rigor no rompen a nadie. Renombrar un parámetro con nombre en un record de argumentos, generalizar una firma para que acepte más casos de los que aceptaba o reordenar constructores expuestos cuentan como modificación de la interfaz y obligan a subir el primer número aunque ningún consumidor real se vea afectado. La regla prefiere equivocarse por exceso, que es la elección correcta para una garantía, pero produce paquetes cuyo primer número avanza más deprisa de lo que la intuición esperaría.

-- Misma firma, semantica distinta: pasa como PATCH sin objecion
ordenar : List Int -> List Int
ordenar = List.sort


ordenar : List Int -> List Int
ordenar = List.sort >> List.reverse
⚠️
Una garantía fuerte mal entendida es peor que ninguna

El riesgo práctico de un mecanismo tan bueno es que induce a bajar la guardia por completo, y conviene enunciar la promesa con exactitud para no confundirla con lo que no es. Lo que garantiza el registro de Elm es que una actualización dentro del mismo primer número seguirá compilando, y esa garantía es muy fuerte porque en Elm compilar significa bastante más que en otros sitios. Lo que no garantiza en absoluto es que el programa haga lo mismo que antes. La actualización automática de dependencias sin leer las notas de la versión sigue siendo imprudente, las pruebas de comportamiento siguen siendo necesarias y la lectura del registro de cambios sigue siendo trabajo del consumidor. La diferencia con otros ecosistemas es real y es grande, pero está acotada: se ha eliminado la ruptura de forma, no la ruptura de conducta.

La diferencia entre pedir una promesa y hacerla imposible de romper

Hay un patrón de diseño que recorre Elm entero y que esta lección exhibe en su forma más pura, más incluso que la ausencia de valores nulos o la exhaustividad del análisis de casos. El patrón consiste en encontrar un punto donde la industria lleva décadas pidiendo disciplina, comprobar que la disciplina falla de forma sistemática y no aleatoria, y entonces cambiar la pregunta: en vez de preguntarse cómo conseguir que la gente cumpla, preguntarse qué tendría que ser cierto del sistema para que incumplir no fuera una opción disponible. La respuesta suele exigir un sacrificio, y el sacrificio suele ser expresividad. Aquí el sacrificio es considerable y conviene mirarlo de frente: para que la interfaz de un paquete sea computable hay que renunciar a la reflexión, al despacho dinámico, a la extensión de tipos ajenos, a la ejecución de código no analizable y a la posibilidad de que un consumidor alcance lo que no le ofrecieron. Cada una de esas renuncias cuesta algo real que en otros lenguajes se usa a diario. Lo que se compra a cambio es que una convención deje de ser una convención y pase a ser una propiedad. Y la lección que sobrevive fuera de Elm no es que debas renunciar a esas capacidades en tu lenguaje, porque probablemente no puedas y probablemente no debas; la lección es aprender a distinguir, en cualquier sistema que diseñes, entre las reglas que se piden y las reglas que se hacen cumplir. Una regla que se pide se cumple mientras haya tiempo, memoria y ganas, y por tanto falla exactamente el día de más presión, que es el día en que más importaba. Una regla que se hace cumplir no depende del estado de ánimo de nadie. Cada vez que en tu equipo aparezca un documento que empieza diciendo que hay que acordarse de, tienes delante una regla del primer tipo y una oportunidad de convertirla en una del segundo, casi siempre a un coste menor del que temes: una comprobación en la integración continua, un tipo más estrecho, un dato que se calcula en vez de escribirse. Elm no descubrió el versionado semántico ni lo mejoró; lo que hizo fue negarse a que siguiera siendo una promesa, y esa negativa es exportable a cualquier lenguaje aunque el mecanismo concreto no lo sea.

⚔️ Somete tus dependencias al mismo examen
  1. Ejecuta la comparación entre dos versiones mayores de un paquete conocido y clasifica cada entrada de la salida en adición, eliminación o modificación.
  2. Publica localmente un cambio que solo añada una función y comprueba que el número calculado sube el segundo dígito y no el primero.
  3. Elimina una función expuesta, pide el cálculo de versión y verifica que la herramienta te obliga a subir el primer número aunque no quieras.
  4. Construye un ejemplo de ruptura de conducta con firma idéntica y razona por qué ningún comparador de interfaces podría detectarlo.
  5. Revisa tres dependencias de un proyecto tuyo en otro lenguaje y busca una actualización de parche que rompiera algo; reconstruye por qué el autor eligió ese número.
  6. Elige una regla de tu equipo que hoy dependa de que alguien se acuerde y diseña la comprobación que la convertiría en una propiedad del sistema.