wandres.dev
SPM Y MÓDULOS · organizar el código

Resolución de dependencias: una versión y solo una

Cómo pasa SwiftPM de un requisito de versión a un commit concreto: identidad de paquete, requisitos disponibles, la regla de versión única y el algoritmo que la impone. Qué significa realmente `Package.resolved`, qué garantiza un target binario con su checksum y qué patologías nacen del grafo transitivo.

⏱ 20 min

Escribir from: "1.2.0" parece una petición modesta y en realidad es una restricción que impones a todos los demás. La resolución de dependencias no busca la versión que a ti te conviene: busca una asignación de versiones que satisfaga simultáneamente todos los requisitos de todos los paquetes del grafo, incluidos los que tú nunca has nombrado y que llegaron por la puerta de atrás. Y lo hace bajo una regla que distingue a SwiftPM de la mayoría de gestores modernos y que condiciona todo lo demás: cada paquete aparece en la construcción exactamente una vez, con una sola versión. No hay copias anidadas, no hay convivencia de dos versiones incompatibles, no hay solución local a un conflicto global. O el sistema encuentra un acuerdo, o no hay construcción.

🎯 Al terminar esta lección sabrás
  • Describir las etapas que llevan de un requisito declarado a un commit concreto y a un artefacto en disco.
  • Explicar la regla de versión única y deducir de ella por qué un rango estrecho es una decisión antisocial.
  • Interpretar Package.resolved, saber cuándo se ignora y usarlo para fijar construcciones reproducibles.
  • Evaluar cuándo un target binario resuelve un problema y qué garantías compra su checksum.

De un requisito a un commit

Antes de comparar versiones, SwiftPM tiene que saber de qué paquete habla. Esa es la identidad, y se deriva de la ubicación: para una dependencia por URL, del último componente de la ruta en minúsculas y sin el sufijo del repositorio; para una dependencia de registro, del par de ámbito y nombre. Dos URLs distintas que produzcan la misma identidad se consideran el mismo paquete y entran en conflicto; dos URLs del mismo repositorio con distinta capitalización se unifican. Buena parte de los conflictos misteriosos del mundo real son en realidad choques de identidad, no de versión.

Los requisitos son cuatro y no son igual de sociables:

.package(url: u, from: "1.2.0"),                      // 1.2.0 ..< 2.0.0
.package(url: u, .upToNextMinor(from: "1.2.0")),      // 1.2.0 ..< 1.3.0
.package(url: u, "1.2.0" ..< "1.5.0"),                // rango explicito
.package(url: u, exact: "1.2.3"),                     // una sola version
.package(url: u, branch: "main"),                     // sin version
.package(url: u, revision: "a1b2c3d")                 // un commit

Las dos últimas formas tienen una restricción dura que conviene conocer antes de usarlas: solo se admiten en el paquete raíz. Un paquete que dependa de una rama no puede ser dependencia por versión de nadie, porque una rama no es un punto fijo y arruinaría la reproducibilidad de todo el grafo aguas abajo. Y exact es casi siempre un error de diseño en una biblioteca: convierte tu preferencia en una obligación para todos los demás y basta con dos bibliotecas que lo usen sobre el mismo paquete para que el grafo sea insatisfacible.

Conviene además no confundir el requisito con la versión resuelta: el requisito es lo que declaras y permanece fijo en el manifiesto; la versión resuelta es lo que el algoritmo elige dentro de la intersección de todos los requisitos del grafo, y puede cambiar sin que tú toques nada, simplemente porque otro paquete publicó una etiqueta nueva.

Con la identidad y los requisitos, el resolutor explora. Descarga la lista de etiquetas de cada candidato, evalúa el manifiesto de las versiones que necesita examinar, deduce nuevos requisitos y repite. Cuando falla, no dice simplemente que no puede: produce una derivación, una cadena de implicaciones que explica qué requisito de qué paquete entra en contradicción con cuál. Leer esa derivación entera, en vez de solo la primera línea, ahorra horas.

⚠️
La regla de versión única no es una limitación, es una consecuencia

En Swift el nombre de un módulo es global y la identidad de un tipo incluye el módulo donde se declaró. Si dos versiones del mismo paquete convivieran, existirían dos tipos distintos con el mismo nombre completo circulando por el mismo proceso, y una conversión entre ambos fallaría de formas que ninguna diagnosis podría explicar. El error de resolución es la versión honesta de ese problema.

Package.resolved y qué significa reproducible

El resolutor produce un fichero que registra, por cada paquete del grafo, su identidad, su origen y el estado exacto en que quedó fijado: la versión y el commit.

{
  "pins": [
    {
      "identity": "swift-collections",
      "kind": "remoteSourceControl",
      "location": "https://github.com/apple/swift-collections.git",
      "state": { "revision": "9d3f7e...", "version": "1.1.4" }
    }
  ],
  "version": 3
}

Hay un detalle que cambia por completo cómo hay que tratarlo: solo cuenta el fichero del paquete raíz. El Package.resolved de una dependencia se ignora por completo. De ahí la regla práctica: en una aplicación o un ejecutable, se versiona en el repositorio y es lo que garantiza que la máquina de integración construya lo mismo que tu portátil; en una biblioteca, es útil para tu propia integración continua y absolutamente invisible para quien te consuma.

Los comandos que lo gobiernan son pocos y conviene no confundirlos.

swift package resolve            # respeta las fijaciones existentes
swift package update             # recalcula dentro de los rangos declarados
swift package update Collections # actualiza solo un paquete
swift build --disable-automatic-resolution   # falla si manifiesto y fijaciones no cuadran
swift package show-dependencies --format tree

Esa última bandera es la que convierte una construcción en verificable: sin ella, un cambio en el manifiesto se resuelve en silencio durante la construcción y el fichero de fijaciones se actualiza solo. Con ella, cualquier discrepancia entre lo declarado y lo fijado es un fallo explícito, que es justo lo que quieres en integración continua. Para entornos cerrados o mirrors internos existe además la configuración de espejos, que redirige una URL a otra sin tocar ningún manifiesto.

Cuando el grafo deja de ser código: targets binarios

Un binaryTarget introduce en el grafo algo que SwiftPM no compila, solo coloca.

.binaryTarget(
    name: "MotorNativo",
    url: "https://ejemplo.com/MotorNativo-2.1.0.xcframework.zip",
    checksum: "5f2a...c91"
)

El checksum no es una comodidad: es el mecanismo entero de integridad. SwiftPM descarga, calcula el hash del archivo y lo compara; si no coincide, se detiene. Como el manifiesto está versionado, cambiar el artefacto sin cambiar el checksum es imposible, y cambiar el checksum es un cambio revisable en el historial. Se calcula con swift package compute-checksum.

Las restricciones son severas y explican por qué un binario debe ser el último recurso. Solo se admiten dos formatos: un xcframework empaquetado, limitado a plataformas de Apple, y un artifactbundle, pensado para ejecutables y sí multiplataforma —es el formato con el que se distribuyen las herramientas que usan los plugins—. No hay fuentes, así que no hay depuración hacia dentro, ni recompilación para otra arquitectura, ni parche local.

Y hay una restricción más profunda, de compatibilidad temporal: un binario que distribuya solo su representación binaria del módulo queda atado a la versión del compilador que lo generó, porque ese formato no es estable entre versiones. Para sobrevivir a las actualizaciones de la cadena de herramientas hay que construirlo con evolución de biblioteca activada, de modo que exponga una interfaz textual del módulo que compiladores futuros puedan volver a leer. Un binario sin esa precaución no es una dependencia: es una cuenta atrás.

flowchart TD
A[App] --> B[Red 1.4.0]
A --> C[Analitica 3.2.0]
B --> D[Registro requiere 2.0.0 hasta 3.0.0]
C --> E[Registro requiere 3.1.0 hasta 4.0.0]
D --> F[Interseccion vacia]
E --> F
F --> G[Error de resolucion con derivacion]
F --> H[Salidas reales: relajar rangos o subir Red]
style F fill:#f38ba8,color:#11111b
style H fill:#a6e3a1,color:#11111b

El grafo transitivo y sus patologías

Lo que declaras es la punta. Lo que se resuelve incluye a los amigos de tus amigos, y ese conjunto tiene propiedades que ninguna dependencia directa anticipa.

El rombo. Dos dependencias tuyas usan una tercera con rangos incompatibles. Bajo la regla de versión única no hay componenda posible: alguien tiene que ceder. La única salida real es que las bibliotecas intermedias declaren rangos amplios, y por eso un .upToNextMinor en una biblioteca publicada es un acto poco solidario: multiplica la probabilidad de que su rango no interseque con el de otro.

La poda de tests. SwiftPM no construye los tests de tus dependencias y ni siquiera resuelve los paquetes que estas usan únicamente en sus targets de prueba. Es lo que impide que una biblioteca de cien kilobytes arrastre un motor de pruebas completo, y es también el motivo de que el grafo que ves con show-dependencies sea más pequeño que la lista de manifiestos que el resolutor tuvo que leer.

El coste de resolver. Cada versión candidata obliga a evaluar un manifiesto, y evaluarlo significa compilarlo y ejecutarlo. Un grafo con muchos paquetes de rangos amplios puede requerir cientos de evaluaciones. Es la razón técnica por la que un manifiesto debe ser barato y puro, y por la que la caché de manifiestos existe.

La dependencia fantasma. Un paquete puede figurar en Package.resolved sin que ningún target lo importe, porque llegó como dependencia de otro que sí lo usa, o porque quedó declarado y nunca se retiró. Las versiones recientes de las herramientas avisan de las dependencias declaradas y no usadas, y merece la pena atender ese aviso: cada una añade descargas, evaluaciones de manifiesto y superficie de confianza a cambio de nada.

La superficie de suministro. Todo paquete del grafo transitivo aporta código que se ejecuta en tu máquina: su manifiesto, sus plugins de construcción y, si lo hay, su binario. La cuenta de confianza no se hace sobre tus tres dependencias directas, sino sobre los cuarenta paquetes que acaban en Package.resolved. Leer ese fichero entero al menos una vez por proyecto es una higiene barata y sorprendentemente reveladora.

🧭

Identidad antes que versión

Dos URLs que colapsan en el mismo nombre son el mismo paquete. Muchos conflictos incomprensibles son choques de identidad disfrazados.

📌

Solo la raíz fija

Las fijaciones de tus dependencias se ignoran. Tu Package.resolved es el único que decide qué se construye.

🤝

Rangos amplios, ecosistema viable

Cada rango estrecho que publicas reduce el espacio de soluciones de todo el que te use. La generosidad aquí es técnica, no cortesía.

Un resolutor es un demostrador de teoremas sobre promesas humanas

La resolución de versiones es, formalmente, un problema de satisfacción de restricciones, y en su forma general es NP-completo: se demostró para el sistema de paquetes de Debian y vale igual para cualquier gestor con rangos y dependencias transitivas. Que en la práctica se resuelva en milisegundos no se debe a que el problema sea fácil, sino a que los grafos reales son escasos y a que algoritmos como PubGrub, el que emplea SwiftPM, importan del mundo de los demostradores SAT la técnica del aprendizaje de cláusulas por conflicto: cuando una combinación falla, no se limitan a probar otra, sino que deducen y memorizan la razón general del fallo, lo que poda de golpe ramas enteras del espacio de búsqueda. Ese mismo aprendizaje es lo que permite que el mensaje de error sea una derivación legible en lugar de un «no se pudo resolver», y es una de las pocas veces en que un algoritmo sofisticado produce una experiencia de usuario mejor y no peor. Ahora bien, hay algo más importante que el algoritmo, y es la naturaleza de sus premisas. Todo este aparato deductivo opera sobre axiomas que ninguna máquina verifica: que la versión 1.4.0 sea compatible con la 1.2.0 no es un hecho comprobado, es una afirmación que alguien escribió al etiquetar. El resolutor es un razonador impecable sobre un conjunto de promesas humanas, y su corrección es exactamente tan sólida como la disciplina del ecosistema que lo alimenta; una versión menor mal etiquetada no produce un error de resolución, produce una construcción que resuelve perfectamente y falla en otro sitio, que es la peor clase de fallo que un sistema puede tener. Sobre ese fondo se entiende la decisión de diseño más discutida de SwiftPM. Los gestores del mundo de JavaScript evitan el rombo permitiendo copias anidadas del mismo paquete en versiones distintas, y esa flexibilidad es real y tiene un precio que no siempre se nombra: en un lenguaje con identidad nominal de tipos, dos versiones convivientes generan tipos homónimos e incompatibles que atraviesan el programa sin que nadie los distinga, y el fallo aparece lejísimos de su causa. Swift renuncia a esa flexibilidad y a cambio conserva una propiedad que vale más: un nombre completo designa una sola cosa en todo el programa. El conflicto de rombo deja de ser un error latente en tiempo de ejecución y pasa a ser un error explícito en tiempo de resolución, con una derivación que explica quién lo causó. No es que SwiftPM tenga un problema que npm resolvió; es que ha elegido tener el problema temprano, ruidoso y legible en lugar de tarde, silencioso e inexplicable.

📝
Lo esencial

Antes que la versión está la identidad, derivada de la ubicación, y muchos conflictos raros son choques de identidad. Los requisitos por rama o por commit solo valen en el paquete raíz, y exact en una biblioteca es una obligación impuesta a terceros. Rige la regla de versión única: un paquete, una versión en toda la construcción, porque el nombre de un módulo es global y la identidad de un tipo incluye su módulo. Solo el Package.resolved de la raíz cuenta, y --disable-automatic-resolution es lo que convierte una construcción en verificable. Un target binario cambia integridad por opacidad, su checksum es toda la garantía y sin interfaz textual del módulo queda atado a un compilador concreto.

⚔️ Rompe y repara el grafo
  1. Ejecuta swift package show-dependencies --format json en un proyecto real y cuenta cuántos paquetes hay que tú no declaraste.
  2. Provoca un rombo a propósito con dos dependencias locales de rangos incompatibles y lee la derivación completa del error.
  3. Modifica a mano un commit en Package.resolved y construye con --disable-automatic-resolution; observa la diferencia con hacerlo sin la bandera.
  4. Sustituye from: por .upToNextMinor en una biblioteca de la que dependan otras dos y comprueba cómo se estrecha el espacio de soluciones.
  5. Empaqueta un binario, calcula su checksum, altera un byte del archivo y verifica en qué punto exacto se detiene la construcción.