Files
takana/docs/plan-catalogo-objetivo.md
T
sergioandClaude Opus 4.8 75c33fffe5 plan: catálogo objetivo — manifiesto de perfiles + estado wanted en el grafo
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>
2026-07-22 17:13:40 -04:00

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-40 ya hace exactamente ese nix eval sacando buildInputs + 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 (ImpuroHermetico en 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 == 0 porque 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 wantedunhashable: 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():

  1. Cargar targets.toml, expandir hereda, obtener el set de raíces por perfil.
  2. 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.
  3. Campo perfiles en 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).
  4. totals gana wanted; bloque nuevo by_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 --apply de nix-import.sh:24-40, que ya devuelve build_inputs + native_build_inputs. No emitir receta: sólo las aristas.
  • alpinemakedepends del APKBUILD, vía alpine-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.json só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 --check en verde y topo_ok: true.
  • P3 no escribe en recipes/ ni en targets.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).