diff --git a/docs/plan-catalogo-objetivo.md b/docs/plan-catalogo-objetivo.md new file mode 100644 index 00000000..3b56c253 --- /dev/null +++ b/docs/plan-catalogo-objetivo.md @@ -0,0 +1,174 @@ +# 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 (`Impuro` → `Hermetico` 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 `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. + +```toml +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. +- **alpine** — `makedepends` 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`).