El grafo de lo que EXISTE cierra perfecto (768 nodos, 0 huérfanas, topo OK). El de lo que la distro DEBE tener no existe: vive en 2 strings de shell de product-userland-from-repo.sh, uno de mirada-usb.sh y 55 tandas planas. De ahí que "cuánto falta" no tenga respuesta. Plan en 5 piezas: targets.toml (perfiles = imágenes, listando RAÍCES y dejando que la clausura salga del grafo), estado `wanted` + membresía de perfil en build-state.py, siembra de aristas desde metadata upstream sin construir, y el drenaje topológico donde harkaq reemplaza la arista hipotética por la medida. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.7 KiB
Plan — catálogo objetivo: del caos al contador que baja
Fecha: 2026-07-22 · Estado: propuesto, listo para ejecutar · Toca: scripts/build-state.py,
docs/state/, scripts/product-userland-from-repo.sh, scripts/mirada-usb.sh
1. El diagnóstico, medido (no intuido)
La pregunta que lo dispara: «¿se puede barrer primero TODOS los paquetes que va a tener la distro y construir el árbol de dependencias de una, en vez de descubrir a los golpes?»
Media respuesta ya está construida. docs/state/build-state.json es ese árbol: 768 nodos,
aristas = deps de build, topo_ok: true, 0 deps huérfanas, 750 selladas / 16 deuda / 2 nunca. Y
cada nodo trae blocked_by (quién me traba) y unblocks (a cuántos destrabo) ⇒ el orden de
masticado ya está calculado (scripts/build-state.py:147-157).
Lo que no existe es el grafo de lo que la distro debe tener. Ese conocimiento vive disperso:
| dónde | qué es | forma |
|---|---|---|
scripts/product-userland-from-repo.sh:26-28 |
el userland real del producto | 2 strings de shell |
scripts/mirada-usb.sh:38 |
la imagen de escritorio mirada | 1 string de shell |
tandas/*.txt |
55 ficheros de campañas pasadas | listas planas, sin aristas |
recipes/incoming-kde/ |
206 recetas del escritorio | una cola, no un objetivo |
De ahí la sensación de caos: el mapa cierra perfecto, pero sólo cubre el territorio ya conquistado. «¿Cuánto falta?» no tiene respuesta porque no hay artefacto que enumere el destino. Y cada frente nuevo re-descubre su propia frontera desde cero.
2. Qué se puede saber de antemano y qué no
Honestidad sobre el límite, porque define el alcance del plan:
- Se puede saber el esqueleto. nixpkgs/Alpine ya tienen el grafo de deps de casi todo. Se
extrae sin construir nada (
scripts/nix-import.sh:24-40ya hace exactamente esenix evalsacandobuildInputs+nativeBuildInputs). - No se puede saber la verdad. Las deps de un paquete no son propiedad del paquete: son propiedad de tu configuración de build. mesa con qué drivers gallium, qué plataformas, qué opciones meson — cambiás una flag y cambia el grafo. Lo importado es el grafo de otra distro con otras decisiones. Aproximación buena, verdad no.
- El tramo final sólo se sabe ejecutando el build — pero cuesta 1 construcción, no N.
«Compilar mesa cinco veces» era el método viejo (probar → fallar → adivinar qué faltó). harkaq ya
convirtió eso de búsqueda en medición: construís una vez, el veredicto por fase dice qué paths
tocó de verdad, los declarás. Es el bucle ya cerrado con zlib (
Impuro→Hermeticoen 3 fases) y el que la campaña desatendida corre en la granja (49 recetas con deps declaradas por el kernel).
Conclusión: el caos no es inevitable, pero no se elimina por adelantado — se convierte. El barrido previo compra el esqueleto y el orden; harkaq compra una medición en vez de cinco adivinanzas. Lo irreducible que queda son las deps que dependen de tus opciones de compilación, y ésas son decisiones, no descubrimientos: van en la receta, no en un bucle de prueba y error.
3. El invariante nuevo
Hoy
orphan_deps == 0porque el grafo sólo contiene lo que existe. El plan lo abre a propósito: la frontera deja de ser invisible y pasa a ser un número.
Ojo con el gate de CI (build-state.py --check, scripts/build-state.py:201): hoy sale 1 si hay
huérfanas o unhashable. El diseño lo preserva — un nodo deseado está declarado, así que una
arista que apunta a él resuelve y orphan_deps sigue significando lo mismo (arista colgando que
nadie declaró ni quiere). La frontera se cuenta con un estado nuevo, wanted, que no es un
fallo. Y wanted ≠ unhashable: el primero es «no hay receta todavía», el segundo «hay receta y
está rota».
4. Las piezas
P1 — docs/state/targets.toml: el manifiesto de objetivo
Un solo fichero declarativo. Un perfil = una imagen enviable.
schema = "hammer-targets/1"
[perfil.base]
descripcion = "userland foundational: la distro arranca y se usa"
paquetes = ["bash", "git", "sudo", "doas", "util-linux", "..."]
[perfil.cli]
hereda = ["base"]
paquetes = ["bat", "fd", "ripgrep", "..."]
[perfil.escritorio-kde]
hereda = ["base"]
paquetes = ["plasma-desktop", "..."]
La inversión clave: paquetes lista sólo las raíces — lo que un usuario pide por nombre. La
clausura sale del grafo, no de la lista. Hoy es al revés: tandas/base-system-3-libs.txt enumera a
mano los miembros de la clausura («libs»). Eso es trabajo que el grafo puede hacer solo.
Migración: los strings de product-userland-from-repo.sh:26-28 pasan a perfil.base +
perfil.cli verbatim, y el de mirada-usb.sh:38 a perfil.escritorio-mirada. Es un
lift-and-shift, no un rediseño. Gate: el PKGS_LIST resultante debe ser idéntico al de hoy —
verificable con un diff de la lista expandida. Ninguna imagen que hoy funciona puede cambiar.
P2 — estado wanted + membresía de perfil en build-state.py
Cambio quirúrgico (~40 líneas) sobre load_recipes() / main():
- Cargar
targets.toml, expandirhereda, obtener el set de raíces por perfil. - Toda raíz —y toda dep de un nodo— que no tenga receta se crea como nodo
{state: "wanted", deps: [], perfiles: [...]}. Deja de ser huérfana y pasa a ser frontera. - Campo
perfilesen TODOS los nodos, propagado por alcanzabilidad desde las raíces. Esto solo ya paga el trabajo: responde «si rompo zlib, qué imágenes se caen» y —más incómodo— «cuáles de las 768 recetas no las necesita ninguna imagen» (sospecha: buena parte de los 362 CLIs Go son catálogo, no distro; conviene saberlo explícitamente en vez de suponerlo). totalsganawanted; bloque nuevoby_profile: por perfil, tamaño de clausura / sellado / deuda / wanted. Ése es el «cuánto falta», por imagen.
Límite honesto que hay que escribir en el header del script: la clausura por perfil necesita las
deps de los nodos wanted, que no se conocen hasta P3. En la primera corrida by_profile es una
cota inferior que crece a medida que se siembra. Mejor un número que converge que ninguno.
P3 — scripts/seed-graph.py: sembrar aristas desde upstream
Para cada nodo wanted sin aristas, sacar la lista de deps de la metadata upstream sin construir:
- nix — reusar tal cual el
nix eval --applydenix-import.sh:24-40, que ya devuelvebuild_inputs+native_build_inputs. No emitir receta: sólo las aristas. - alpine —
makedependsdel APKBUILD, víaalpine-import.sh.
Salida a un fichero aparte, docs/state/seed-edges.json, con procedencia por arista
({dep, fuente: "nix"|"alpine", fecha}). Nunca a targets.toml, nunca a recipes/.
Disciplina, en negrita porque es donde esto se puede pudrir: la semilla es una HIPÓTESIS, no la
verdad. Los nombres son de upstream (pkg-config no existe en hammer, que usa zig-cc; stdenv y
los hooks de autoreconf no son nada acá). Hace falta una tabla de mapeo de nombres y una lista de
descarte de nix-ismos. Va a haber ruido. Su único trabajo es hacer la frontera contable y
ordenable, no correcta. Nota de ADR: leer la metadata de nixpkgs no viola el ADR 0004 — lo que
prohíbe es construir con nix, y acá no se construye nada.
P4 — el bucle de drenaje, y dónde harkaq cierra el lazo
Regla de fuente única de verdad:
seed-edges.jsonsólo se consulta para nodos SIN receta. En cuanto hay receta, manda la receta.
Con eso la semilla es auto-borrable: cada paquete construido reemplaza su arista hipotética por
la medida (la receta, ya corregida por el veredicto de harkaq — que es literalmente lo que hace la
campaña desatendida hoy). Al final del frente, seed-edges.json queda vacío. Si no se vacía, eso
mismo es la lista de lo que falta.
Orden de ataque por perfil: topológico, priorizado por unblocks — ya se calcula en
build-state.py:151-157, sólo hay que incluir los nodos wanted.
P5 — jubilar las tandas (degradarlas, no borrarlas)
tandas/ pasa a ser archivo de campañas pasadas (55 ficheros con historia, no se tiran). El
trabajo nuevo sale de una vista derivada del grafo: los próximos N nodos wanted construibles ya
(sin blocked_by) en orden topológico. El lazo de la granja (vps-nunca-idle, la cosecha cron) se
alimenta de ahí en vez de listas escritas a mano.
Eso es lo que de verdad mata la sensación de caos: la cola del worker deja de ser autoría y pasa a ser derivada.
5. Secuencia (commits chicos, valor en el primero)
| paso | qué | riesgo | esfuerzo |
|---|---|---|---|
| P1 | targets.toml + lift-and-shift de los 3 strings; gate = lista expandida idéntica |
bajo | ~1h |
| P2 | wanted + perfiles + by_profile en build-state.py; regenerar y commitear el diff del JSON |
bajo | ~1-2h |
| P3 | seed-graph.py contra un solo perfil (escritorio-kde: el más duro y el de mayor valor, 206 recetas con frontera desconocida). Medir la tasa de ruido antes de generalizar |
medio | ~medio día |
| P4 | enchufar el drenaje a la cola de la granja | medio | tras P3 |
P1+P2 es el corte mínimo viable y ya entrega el primer número de «cuánto falta por imagen». P3 no se generaliza hasta conocer su ruido.
Gates:
- P2 mantiene
--checken verde ytopo_ok: true. - P3 no escribe en
recipes/ni entargets.toml. - Ninguna imagen que hoy bootea cambia de contenido por P1.
6. Coordinación
Toca ficheros compartidos (scripts/build-state.py, docs/state/). Hay otro agente trabajando en
el repo ⇒ acordar antes de P1/P2, y nunca git add -A (memoria etapa-g-frente-go).