elm.json y el gestor de paquetes: un ecosistema que no depende de la buena voluntad
El archivo de dependencias de un proyecto es, en casi todos los ecosistemas, un documento de intenciones: declara rangos que alguien resolverá más tarde, se acompaña de un fichero de bloqueo generado por una herramienta distinta, y admite fuentes de código que ninguna garantía cubre. Esta lección estudia por qué elm.json es otra cosa. Se analiza la partición del archivo en dos tipos de proyecto irreconciliables, la distinción entre dependencias directas e indirectas y el motivo por el que esa distinción hace innecesario el fichero de bloqueo, la naturaleza del resolutor que produce una única solución exacta en lugar de una aproximación negociada, y la cadena de restricciones del registro central que convierte el versionado semántico en una propiedad calculada y no en una promesa emitida. Después se examina, con la honestidad que el tema merece, qué se paga por esa estabilidad: un catálogo pequeño, un privilegio reservado a los paquetes del núcleo, y una comunidad que crece despacio porque publicar cuesta más que en cualquier otro sitio.
Un gestor de paquetes es la pieza de infraestructura que menos se mira y más determina la vida de un proyecto a los tres años. Durante la primera semana todos parecen equivalentes: se escribe un comando, aparece un directorio con dependencias y el programa arranca. La diferencia se manifiesta después, cuando alguien intenta actualizar algo, cuando una compilación que funcionaba en una máquina falla en otra, cuando un paquete transitivo que nadie eligió conscientemente introduce una ruptura, o cuando hay que auditar qué código exactamente se está ejecutando. Elm tomó en este terreno un conjunto de decisiones que en su momento parecieron rigideces gratuitas y que hoy explican una propiedad observable y poco frecuente: proyectos escritos hace seis años compilan sin tocar una línea, y actualizar dependencias no es un ritual de riesgo sino una operación aburrida. Esa propiedad no es fortuna ni juventud del ecosistema. Es la consecuencia mecánica de que elm.json no describa deseos sino hechos, y de que el registro se niegue a aceptar cualquier cosa que no pueda comprobar por sí mismo.
- Distinguir los dos tipos de
elm.jsony explicar por qué una aplicación fija versiones exactas y un paquete declara intervalos. - Explicar la función de las dependencias indirectas y por qué su presencia en el archivo elimina la necesidad de un fichero de bloqueo aparte.
- Describir el resolutor de restricciones y el momento exacto en que una instalación se rechaza en lugar de resolverse a la fuerza.
- Delimitar qué garantiza el registro central, qué no garantiza, y qué coste real impone esa garantía sobre el tamaño del catálogo.
Dos tipos de proyecto, un solo archivo
La primera decisión que se ve al abrir el archivo es que el campo inicial declara si esto es una aplicación o un paquete, y que ambos casos tienen esquemas distintos e incompatibles. No es una etiqueta informativa: cambia el significado de todo lo que viene después. Una aplicación es un artefacto terminal, algo que se compila y se despliega, y por tanto necesita saber exactamente qué versión de cada cosa va dentro. Un paquete es una biblioteca destinada a convivir con otras que no conoce, y por tanto no puede exigir versiones exactas sin volverse incompatible con la mitad del catálogo. La aplicación fija; el paquete negocia. Que el formato del archivo distinga los dos casos en lugar de ofrecer un esquema único con convenciones evita de raíz la confusión más cara del ecosistema vecino, donde el mismo package.json sirve para las dos cosas y la diferencia queda relegada a la disciplina de quien escribe los rangos.
{
"type": "application",
"source-directories": [ "src" ],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": { "direct": {}, "indirect": {} }
}
La segunda decisión es la partición entre directas e indirectas, y es más profunda de lo que aparenta. Las directas son las que el código del proyecto importa; las indirectas son las que alguna directa necesita para funcionar y que ningún módulo propio menciona jamás. Ambas están escritas en el archivo, con versión exacta, bajo control de versiones y a la vista. Aquí desaparece una categoría entera de problemas: no hay un archivo de bloqueo generado por otra herramienta que pueda divergir del archivo de declaración, no existe la pregunta de cuál de los dos manda, y auditar qué código entra en la compilación es leer un solo documento. Si dos personas clonan el repositorio, compilan el mismo grafo exacto, sin condiciones de carrera con la red ni resoluciones distintas según la fecha.
{
"type": "package",
"name": "autor/utilidades-de-formato",
"summary": "Formateo de fechas y numeros para interfaces",
"license": "BSD-3-Clause",
"version": "2.1.0",
"exposed-modules": [ "Formato.Fecha", "Formato.Numero" ],
"elm-version": "0.19.0 <= v < 0.20.0",
"dependencies": {
"elm/core": "1.0.0 <= v < 2.0.0",
"elm/time": "1.0.0 <= v < 2.0.0"
},
"test-dependencies": {}
}
Aplicación
Versiones exactas, directas e indirectas explícitas, sin fichero de bloqueo. El archivo es la compilación reproducible.
Paquete
Intervalos con extremo inferior incluido y superior excluido, módulos expuestos declarados, número de versión calculado.
Sin lockfile
No hay dos fuentes de verdad que puedan divergir. Lo declarado y lo instalado son literalmente lo mismo.
Sin código ajeno
Ningún paquete publicado puede incrustar JavaScript. El grafo entero es analizable por el compilador.
El resolutor que prefiere fallar antes que inventar
Instalar una dependencia no es descargar un directorio: es pedir a un resolutor que encuentre una asignación de versiones exactas compatible con todos los intervalos declarados por todo el grafo transitivo. La herramienta calcula esa asignación, escribe el resultado completo en el archivo y actualiza las indirectas que hagan falta. Si el proyecto ya no compila con la nueva combinación, se propone también rebajar o subir otras versiones para encontrar una solución que sí funcione. Y si no existe ninguna asignación que satisfaga todas las restricciones a la vez, la instalación se rechaza con una explicación del conflicto en lugar de completarse con una solución parcial que estallará más tarde.
# Anade la dependencia y recalcula el grafo entero
elm install elm/http
# Comprueba que el grafo declarado compila de verdad
elm make src/Main.elm --output=/dev/null
# Inspeccionar la interfaz publica de un paquete antes de adoptarlo
elm diff elm/http 1.0.0 2.0.0
Merece la pena mirar de cerca en qué se diferencia esto de una instalación convencional, porque el contraste es instructivo. En el ecosistema vecino la operación habitual escribe un rango en el archivo de declaración, resuelve una asignación concreta en un fichero aparte y, si el conflicto entre dos consumidores es irresoluble, lo evita duplicando la biblioteca en dos versiones anidadas. Las tres decisiones tienen sentido por separado y juntas producen un sistema donde nadie sabe de memoria qué versión de qué cosa se está ejecutando. Aquí la operación escribe versiones exactas en el único archivo que hay, y cuando el conflicto es irresoluble se detiene. Detenerse es una respuesta peor a corto plazo y mucho mejor a medio: convierte un problema de compatibilidad en un evento visible, con fecha y con responsable, en lugar de diferirlo a una fecha desconocida.
-- DEPENDENCIAS INCOMPATIBLES ---------------------------------
No he podido encontrar una combinacion compatible:
autor/formularios 3.2.0 requiere elm/json 2.0.0 <= v < 3.0.0
otro/tablas 1.4.0 requiere elm/json 1.0.0 <= v < 2.0.0
Solo puede haber una version de cada paquete en la compilacion.
En el ecosistema de JavaScript es normal que dos versiones distintas de una misma biblioteca convivan en el mismo árbol, cada una anidada bajo su consumidor. Esa solución resuelve el conflicto sin negociación, y a cambio infla el artefacto y produce fallos difíciles de explicar cuando dos instancias de la misma biblioteca mantienen estados separados que deberían ser uno. Elm no admite duplicados: en una compilación hay como mucho una versión de cada paquete. La decisión sería insostenible si el resolutor tuviera que trabajar con rangos poco fiables, pero como el registro garantiza que un cambio del primer número es la única forma de romper la compatibilidad de forma, los intervalos declarados son creíbles y el resolutor encuentra solución casi siempre. La restricción fuerte en la publicación es lo que permite la restricción fuerte en la resolución.
flowchart TD A[elm install pide un paquete] --> B[Leer intervalos de todo el grafo] B --> C[Buscar una asignacion exacta unica] C --> D[Existe solucion] C --> E[No existe solucion] D --> F[Escribir directas e indirectas en elm.json] E --> G[Rechazar e informar del conflicto] F --> H[Compilacion reproducible] style C fill:#cba6f7,color:#11111b style H fill:#a6e3a1,color:#11111b style G fill:#f38ba8,color:#11111b
Lo que sostiene la estabilidad del catálogo
La reproducibilidad del archivo sería poca cosa si el catálogo del que bebe fuera inestable. Lo que la hace valiosa es un conjunto de reglas que el registro central aplica en el momento de publicar y que no se pueden esquivar. El número de versión no lo elige el autor sino que lo calcula la herramienta comparando la interfaz pública nueva con la ya publicada, de modo que una eliminación o un cambio de firma obligan a subir el primer número aunque el autor prefiera no hacerlo. Todo paquete empieza en la primera versión estable, con lo que desaparece el limbo de las versiones cero en las que la convención declara que nada garantiza nada. Toda función expuesta debe llevar documentación y esa documentación se valida al publicar. Los intervalos se expresan siempre con extremo inferior incluido y superior excluido, forma que el resolutor puede tratar sin heurísticas. Y ningún paquete puede contener JavaScript arbitrario, lo cual mantiene el grafo entero dentro del alcance del análisis de tipos.
El efecto acumulado de esas reglas es que la actualización deja de ser un acto de fe. Subir una dependencia dentro del mismo primer número no puede romper la compilación, y como en Elm compilar significa bastante más que en otros lenguajes, esa garantía cubre una porción muy grande del riesgo. Lo que no cubre, y conviene decirlo sin adornos, es el cambio de conducta: una función que conserva su firma exacta y pasa a ordenar al revés, a redondear de otro modo o a distinguir mayúsculas se publica legítimamente como parche y rompe a sus usuarios igual que una eliminación. La garantía es de forma, no de significado.
Hay un efecto secundario de este régimen que cambia la manera de diseñar bibliotecas y que conviene señalar porque explica el estilo del catálogo. Como exponer algo es un compromiso que solo puede deshacerse subiendo el primer número, el autor aprende deprisa a exponer lo mínimo, y en particular a preferir los tipos opacos, cuyos constructores no se publican, sobre los tipos con constructores visibles. Un tipo cuyos constructores forman parte de la interfaz convierte cada uno de ellos en contrato y hace que añadir un caso interno sea una ruptura; un tipo opaco acompañado de funciones de construcción y consulta permite reorganizar la representación entera sin que la interfaz se mueva. La herramienta no obliga a ese estilo, pero lo recompensa con tanta claridad que el catálogo converge hacia él.
Conviene también entender la relación entre este régimen y la seguridad de la cadena de suministro, porque es distinta de la que ofrecen otros gestores. No hay guiones que se ejecuten al instalar, no hay pasos de construcción arbitrarios y no hay código nativo en los paquetes de la comunidad, de modo que instalar una dependencia no ejecuta nada: descarga fuentes que después el compilador analiza. La superficie de ataque clásica de los gestores de paquetes, que consiste en que el propio acto de instalar ejecute código del atacante, sencillamente no existe aquí. Lo que sí queda es el riesgo ordinario de que una dependencia haga algo indeseable cuando el programa se ejecute, y frente a eso la defensa es la lectura del código y la ausencia de puertos inesperados, no el gestor.
Un régimen tan estricto tiene consecuencias que nadie debería ocultar al evaluar el lenguaje. El catálogo es pequeño comparado con el de cualquier ecosistema grande, porque publicar exige documentación completa, interfaz estable y ausencia de código nativo, y ese listón deja fuera a mucha gente que en otro sitio habría publicado una utilidad de veinte líneas. La prohibición de incrustar JavaScript significa que envolver una biblioteca existente no puede hacerse desde un paquete publicable, sino desde el proyecto que la usa, mediante puertos o elementos personalizados, lo cual duplica trabajo entre equipos que resuelven lo mismo. Y existe una asimetría real: los paquetes del núcleo sí pueden contener código nativo, privilegio que la comunidad no comparte y que ha sido una de las fuentes de fricción más persistentes en la historia del proyecto. Quien adopte Elm debería contar con que algunas integraciones habrá que escribirlas en casa.
Merece la pena separar dos cosas que casi siempre se confunden cuando se compara un gestor de paquetes con otro: la comodidad de instalar y la fiabilidad de lo instalado. Casi toda la innovación de la última década se ha concentrado en la primera, y con éxito notable, porque instalar es hoy incomparablemente más rápido que hace diez años. La segunda apenas se ha movido, y la razón es que no depende del gestor sino de una propiedad del lenguaje que el gestor no puede fabricar por su cuenta: la posibilidad de calcular, sin ejecutar nada, si dos versiones de un paquete son compatibles. Donde esa propiedad no existe, todo lo que puede hacerse es congelar el estado del mundo en un fichero de bloqueo y confiar en que nadie tenga que descongelarlo, que es exactamente la razón por la que tantos equipos acumulan deuda de dependencias hasta que la actualización se convierte en un proyecto con nombre propio. Elm no resolvió el problema mejorando la herramienta; lo resolvió sacrificando expresividad en el lenguaje hasta que la pregunta se volvió computable, y después construyó la herramienta más simple posible sobre esa base. La lección exportable no es que debas prohibir la reflexión ni el código nativo en tu ecosistema, porque probablemente no puedas hacerlo. Es que la fiabilidad de un sistema de dependencias está acotada por lo que ese sistema puede verificar, no por lo que sus participantes prometen, y que cada vez que una garantía descanse sobre la disciplina de miles de personas repetida miles de veces, esa garantía es estadística y fallará. La pregunta útil ante cualquier ecosistema no es cuántos paquetes tiene ni cuánto tarda en instalar, sino esta otra: cuando actualizo un dígito, quién ha comprobado que eso es cierto, y con qué. Si la respuesta es nadie, el número no es información.
- Abre el
elm.jsonde un proyecto y clasifica cada dependencia en directa e indirecta; para cada indirecta, averigua qué directa la arrastra y por qué. - Instala un paquete que fuerce al resolutor a mover otras versiones y observa qué líneas del archivo cambian sin que tú las tocaras.
- Construye a propósito un conflicto de restricciones entre dos paquetes y lee el mensaje de rechazo; explica qué asignación buscaba el resolutor y por qué no existía.
- Compara la interfaz pública de dos versiones mayores de un paquete y clasifica cada entrada de la salida en adición, eliminación o modificación.
- Toma un proyecto tuyo en otro lenguaje, cuenta cuántos paquetes transitivos entran en la compilación y compáralo con el número de líneas del archivo que declaras a mano.
- Argumenta en contra de esta lección: defiende que permitir código nativo en los paquetes de la comunidad habría sido mejor, y enumera con honestidad qué garantías concretas se habrían perdido.