Package.swift: el manifiesto es un programa
El manifiesto de SwiftPM no es un fichero de configuración sino código Swift que se compila y se ejecuta aislado para producir un modelo inerte. Anatomía de `swift-tools-version`, la diferencia real entre producto y target, las dos capas de dependencias y por qué la expresividad del lenguaje es aquí una trampa.
Casi todos los sistemas de construcción se configuran con datos: un YAML, un TOML, un XML. SwiftPM eligió lo contrario y su manifiesto es un programa Swift completo, con tipos, funciones y comprobación estática. La consecuencia inmediata es agradable: el editor autocompleta, el compilador rechaza un campo mal escrito antes de que exista un solo fichero fuente y el modelo del paquete tiene una gramática de verdad en lugar de un acuerdo tácito sobre nombres de claves. La consecuencia mediata es más sutil y es la que hay que entender: si el manifiesto es un programa, alguien tiene que ejecutarlo, y lo hace en condiciones muy restringidas y en un momento muy temprano. Package.swift no construye tu paquete; construye una descripción de tu paquete, la entrega y se aparta.
- Explicar qué hace SwiftPM con
Package.swiftdesde que lo lee hasta que planifica la compilación. - Distinguir con precisión producto, target y dependencia, y saber qué declara cada uno.
- Escribir dependencias en sus dos capas: entre paquetes y entre targets.
- Reconocer qué lógica no debe vivir jamás en un manifiesto y por qué.
Un programa que se ejecuta una vez y no compila nada
La primera línea no es un comentario, aunque lo parezca.
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Tejido",
platforms: [.macOS(.v14), .iOS(.v17)],
products: [
.library(name: "Tejido", targets: ["Tejido"]),
.executable(name: "tejido-cli", targets: ["TejidoCLI"])
],
targets: [
.target(name: "Nucleo"),
.target(name: "Tejido", dependencies: ["Nucleo"]),
.executableTarget(name: "TejidoCLI", dependencies: ["Tejido"]),
.testTarget(name: "TejidoTests", dependencies: ["Tejido"])
]
)
Esa línea inicial selecciona tres cosas a la vez: la versión mínima de herramientas capaz de abrir el paquete, la versión de la biblioteca PackageDescription contra la que se compila el manifiesto y el conjunto de comportamientos por defecto que se aplican. Subirla no es cosmético: cambia semántica silenciosa, como qué se considera un fichero fuente o qué modo de lenguaje se asume.
El resto es Swift ordinario. SwiftPM compila ese fichero, lo enlaza contra PackageDescription y lo ejecuta en un entorno aislado: sin red, sin acceso de escritura salvo a un directorio temporal, sin variables de entorno fiables. El único efecto que sobrevive a esa ejecución es el valor Package construido, que se serializa a JSON y vuelve al gestor. A partir de ahí el programa ha muerto y solo queda su resultado, un dato inerte. Todo lo demás que hicieras dentro —imprimir, medir el tiempo, leer un fichero— o falla o se pierde.
SwiftPM guarda el resultado de evaluar cada manifiesto indexado por su contenido y el de la cadena de herramientas. Si haces que el modelo dependa de algo externo al fichero, ese algo puede cambiar sin que la caché se invalide, y obtendrás un grafo antiguo sin ningún aviso. Un manifiesto que no es una función pura de su propio texto es un manifiesto roto que a veces funciona.
Productos y targets: dos grafos, no uno
Un target es una unidad de compilación: se convierte exactamente en un módulo de Swift. Un producto es una unidad de consumo: es lo que otros paquetes pueden pedir. Confundirlos es el error estructural más frecuente al empezar.
Los targets viven hacia dentro. .target produce una biblioteca, .executableTarget un binario con punto de entrada, .testTarget una suite, .binaryTarget un artefacto ya compilado, .macro un plugin del compilador y .systemLibrary un envoltorio sobre una biblioteca del sistema. Por convención sus fuentes están en Sources/NombreDelTarget, y path, sources y exclude permiten desviarse de ella cuando hay motivo.
Los productos miran hacia fuera. .library puede llevar un tipo explícito —.static o .dynamic— o dejarlo sin especificar, que es lo recomendable: así el gestor decide según el contexto de enlazado. Forzar .dynamic sin necesidad cuesta arranque y complica la distribución; forzar .static puede duplicar símbolos si dos productos comparten el mismo target.
Entre ambos grafos hay una relación de muchos a muchos que conviene tener presente: un producto puede agrupar varios targets y un mismo target puede aparecer en varios productos. Eso permite publicar una biblioteca compuesta sin renunciar a compilar sus piezas por separado, que es el motivo principal por el que un paquete se parte en módulos: los tiempos de compilación incremental dependen del tamaño del módulo, no del paquete.
La regla que ordena todo lo demás: solo cruza la frontera del paquete lo que algún producto expone. Un target que no es alcanzable desde ningún producto es invisible desde fuera por construcción, sin necesidad de ninguna palabra clave. Ese es el primer nivel de control de acceso y el más grueso, y es el que hace posible tener quince módulos internos y publicar solo dos.
flowchart LR subgraph Paquete T1[Nucleo] --> T2[Tejido] T2 --> P1[Producto libreria Tejido] T3[TejidoCLI] --> P2[Producto ejecutable tejido-cli] T2 --> T3 T4[TejidoTests] end P1 --> C[Otro paquete que importa] T1 -.no visible fuera.-> C style P1 fill:#a6e3a1,color:#11111b style T1 fill:#89b4fa,color:#11111b style T4 fill:#f9e2af,color:#11111b
Las dos capas de dependencias
Declarar una dependencia de paquete no hace importable nada. Son dos declaraciones distintas en dos sitios distintos, y hacen falta las dos.
dependencies: [
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"),
.package(path: "../Cimientos")
],
targets: [
.target(
name: "Nucleo",
dependencies: [
.product(name: "Collections", package: "swift-collections"),
.target(name: "Auxiliar"),
.product(name: "Red", package: "Cimientos", condition: .when(platforms: [.linux]))
]
)
]
La primera capa, en Package, dice de dónde se saca el código: una URL con un requisito de versión, una ruta local, un identificador de registro. La segunda, en cada target, dice quién puede importarlo: solo los targets que lo nombran obtienen el módulo en su ruta de búsqueda. Esa separación es deliberada y es lo que permite que un paquete dependa de algo pesado y que solo un target lo pague.
Escribir una cadena suelta como dependencia de target usa la resolución byName, que busca primero un target del mismo paquete y luego un producto con ese nombre. Es cómodo y frágil: el día que aparezca un target homónimo, el significado cambia sin que nada falle. Prefiere siempre la forma explícita, .target para lo propio y .product para lo ajeno.
Recursos, ajustes y el límite de la expresividad
Los recursos se declaran por target y en dos modos que no son intercambiables. .process aplica el tratamiento propio de la plataforma —compilar un catálogo de activos, optimizar una imagen— y aplana la estructura; .copy conserva el árbol tal cual, byte a byte. Ambos generan el acceso mediante Bundle.module, que solo existe si el target declara al menos un recurso.
Los ajustes de compilación viven en swiftSettings, cSettings y linkerSettings, y admiten activar características futuras, fijar el modo de lenguaje o definir símbolos. Hay uno que conviene tratar como veneno:
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
.unsafeFlags(["-Ounchecked"]) // contagia una restriccion a todo el ecosistema
]
Un paquete que usa unsafeFlags no puede ser dependencia por versión de nadie: SwiftPM solo lo acepta como paquete raíz o referenciado por rama o ruta. Es una restricción viral y casi siempre inesperada, y basta para arruinar la publicación de una biblioteca por un ajuste que se puso una tarde para probar algo.
Queda el límite grande. En un manifiesto puedes escribir bucles, condicionales y llamadas al sistema de ficheros, y no debes. Hay cuatro razones y ninguna es estética. La caché indexa por contenido, así que cualquier dependencia externa produce resultados obsoletos invisibles. El aislamiento prohíbe la red y casi toda la escritura, de modo que la mitad de lo que se te ocurra fallará en la máquina de integración y no en la tuya. El resolutor necesita leer el manifiesto de cada versión candidata de cada paquete del grafo, a veces cientos, así que un manifiesto caro se paga multiplicado. Y la reproducibilidad exige que dos máquinas deriven el mismo modelo del mismo texto. El uso legítimo del lenguaje aquí es otro: extraer constantes, factorizar listas repetidas y escribir funciones puras sobre literales.
Denotación, no receta
El manifiesto no ordena pasos: produce un valor. Su significado es el modelo que devuelve, y nada de lo que ocurra durante su ejecución forma parte del contrato.
Producto es la puerta
El target es tu módulo; el producto es lo que el mundo ve. Todo lo que no cuelgue de un producto es privado del paquete sin escribir una sola palabra clave.
Dos declaraciones
Añadir el paquete no da acceso al módulo. Hasta que un target no nombra el producto, ese import no compila.
La historia de los sistemas de construcción es un péndulo entre dos fracasos simétricos. En un extremo está la configuración puramente declarativa, que empieza limpia y termina inventando condicionales, plantillas y sustitución de variables hasta convertirse en un lenguaje de programación pésimo que nadie diseñó; es lo que se ha llamado el reloj de la complejidad de la configuración, y la industria le ha dado una vuelta completa varias veces. En el otro está la configuración como script, con Turing completo desde el primer día, que empieza cómoda y termina siendo imposible de analizar: nadie puede saber qué construye un fichero de Gradle sin ejecutarlo, y ejecutarlo puede tardar minutos y depender de la red. SwiftPM escoge un tercer camino que merece llamarse por su nombre: usar un lenguaje real como sintaxis y prohibirle ser un lenguaje real como semántica. El manifiesto se compila, se ejecuta una vez en una caja sin salidas y se reduce de inmediato a datos; lo que sobrevive no es un procedimiento sino una denotación, un valor que puede serializarse, cachearse, compararse y transmitirse a otra herramienta que no sabe nada de Swift. Ese movimiento —conservar la ergonomía del código y quedarse con la semántica del dato— es exactamente el mismo que hicieron Starlark en Bazel al amputar deliberadamente la recursión y el acceso al sistema, o Nix al exigir pureza para poder cachear derivaciones. La razón profunda no es el gusto por la austeridad, es que la resolución de dependencias obliga a evaluar los manifiestos de versiones que quizá nunca construyas: para explorar el grafo hay que poder preguntar barato y muchas veces qué dependencias declara la versión 1.4.2 de un paquete que no vas a usar. Un manifiesto que no sea barato, puro y total rompe esa exploración, y con ella la posibilidad misma de resolver. Cuando alguien propone leer una variable de entorno en Package.swift no está pidiendo una comodidad menor: está pidiendo que el grafo de dependencias deje de ser una función y pase a ser un experimento, y la diferencia entre esas dos cosas es la diferencia entre una construcción que se puede razonar y una que solo se puede repetir con los dedos cruzados.
La primera línea fija a la vez la versión mínima de herramientas, la biblioteca contra la que se compila el manifiesto y los valores por defecto vigentes. El manifiesto se ejecuta una sola vez, aislado y sin red, y de esa ejecución solo sobrevive el modelo serializado. El target es tu módulo y el producto es la puerta al exterior: lo que no cuelga de un producto no existe fuera. Las dependencias se declaran dos veces, en el paquete para saber de dónde vienen y en cada target para saber quién puede importarlas. Y el manifiesto debe ser una función pura y barata de su propio texto, porque el resolutor lo evaluará muchas más veces de las que imaginas.
- Ejecuta
swift package dump-packagey compara el JSON resultante con el texto del manifiesto: localiza qué valores por defecto ha rellenado el gestor. - Crea un paquete con tres targets y un solo producto, e intenta importar desde otro paquete el target que no cuelga del producto.
- Cambia una biblioteca de automática a
.dynamicy mide el efecto sobre el tiempo de arranque del ejecutable. - Añade
.unsafeFlagsa un target e intenta consumir ese paquete por versión desde otro; anota el mensaje exacto de error. - Mete un
printy una lectura de fichero en el manifiesto y averigua dónde acaba la salida y con qué falla la lectura.