gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 746d2dedd17905b67f8977018d27615e38c70ded scripts/build-state.py Python · 205 líneas · 9.8 KB Historia Quién tocó qué crudo

estado: buscar el artefacto por el name INTERNO, no el basename del fichero 4357cd7b0 · sergio ·

1#!/usr/bin/env python3
2# build-state.py — el GRAFO DE ESTADO del corpus, en un formato firme y versionado.
3#
4# POR QUÉ EXISTE. El estado real del build (qué está sellado-al-día, qué es deuda de rebuild, qué
5# depende de qué) vivía disperso: en la cabeza del que construía, en docs que envejecen (el de
6# matar-gcc decía 47 y eran 16), en el store (que guarda TODOS los sellados históricos, no sólo el
7# vigente). Cada medición a mano mentía distinto. Este script deriva el estado de la ÚNICA fuente de
8# verdad —las recetas + `hammer hash` + el store— y lo escribe a `docs/state/build-state.json`.
9# Versionado: el `git diff` de ese fichero ES el avance entre dos corridas (qué se saldó, qué se
10# rompió). La vista humana la arma `build-state-view.py` a partir del mismo JSON.
11#
12# NODO = receta: {name, class, link, compiler, deps[], hash, state}.
13#   state ∈ { sealed | debt | never | unhashable }
14#     sealed  — el artefacto del hash VIGENTE (el de la receta de hoy) está en el store. Al día.
15#     debt    — hay sellados históricos pero NINGUNO es el vigente ⇒ la receta cambió, falta rebuild.
16#     never   — no hay ningún sellado de esta receta en el store.
17#     unhashable — `hammer hash` falló (receta ilegible / dep rota). Un HUECO real del grafo.
18# ARISTA = dep de build (name → dep). El grafo debe CERRAR: toda dep apunta a una receta del corpus.
19#
20# Uso:   scripts/build-state.py                 # regenera docs/state/build-state.json + resumen
21#        scripts/build-state.py --check         # exit 1 si hay unhashable o deps huérfanas (gate CI)
22# Env:   HAMMER (def target/release/hammer), STORE (def ./store).
23import json, os, subprocess, sys, glob, tomllib
24from pathlib import Path
25
26ROOT = Path(__file__).resolve().parent.parent
27os.chdir(ROOT)
28HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/hammer"))
29STORE = os.environ.get("STORE", str(ROOT / "store"))
30CHECK = "--check" in sys.argv
31# `--kde`: además del corpus canónico, cubre la cola `recipes/incoming-kde/` (206 recetas del
32# escritorio KDE Plasma). Las KDE SOMBREAN a las canónicas homónimas (`fontconfig` vs KDE, `dbus`…)
33# igual que hace el sandbox al resolver deps (sibling-first), así el grafo modela lo que un build KDE
34# ve. Sale a un JSON aparte para no mezclar los totales del corpus enviable con el frente en curso.
35KDE = "--kde" in sys.argv
36EXTRA_QUEUES = ["recipes/incoming-kde"] if KDE else []
37OUT = ROOT / ("docs/state/build-state-kde.json" if KDE else "docs/state/build-state.json")
38
39# Clase de la receta — sólo para colorear/agrupar la vista; heurística por contenido, no dato duro.
40GUI_RE = ("cairo", "pango", "gtk", "harfbuzz", "glib", "gdk-pixbuf", "libadwaita", "gtksourceview",
41          "fribidi", "graphene", "fontconfig", "adwaita", "fcft", "mirada", "appstream", "gnome",
42          "wayland", "libdrm", "mesa", "seatd", "libepoxy", "libxkbcommon", "libinput", "pixman",
43          "sourceview", "tllist", "foot")
44
45def classify(name, d):
46    ph = d.get("build", {}).get("phases", {})
47    blob = " ".join(str(ph.get(k, "")) for k in ("configure", "compile", "install"))
48    deps = d.get("deps", {}).get("build", [])
49    if name.startswith("linux"):
50        return "kernel"
51    # Go: declara el toolchain `go` como dep de build (la vía Go auto lo exige). 362 recetas.
52    if "go" in deps and name != "go":
53        return "go"
54    # Rust: fase cargo, dep rust, o —la señal fuerte— copia el binario de `target/release/` en sus
55    # fases (todo binario cargo sale de ahí). Medido: 225 recetas lo tienen, 0 con dep `go` ⇒ sin
56    # falsos positivos con Go. LÍMITE conocido: un crate BuildSys::Cargo PURO (Cargo.toml en el árbol,
57    # sin fase install propia — p.ej. zellij) no deja rastro en el TOML y cae a "c"; sólo se distingue
58    # bajando la fuente, que este generador no hace a propósito.
59    if "cargo" in blob or "cargo" in deps or "rust" in deps or "target/release" in blob:
60        return "rust"
61    if any(g in name for g in GUI_RE):
62        return "gui"
63    return "c"
64
65def load_recipes():
66    recs = {}
67    # Orden importa: primero el corpus (queue=corpus), luego las colas extra que SOMBREAN por nombre
68    # (queue=el basename de la cola), replicando el sibling-first del sandbox.
69    for root, queue in [("recipes", "corpus")] + [(q, os.path.basename(q)) for q in EXTRA_QUEUES]:
70        for f in sorted(glob.glob(f"{root}/*.toml")):
71            n = os.path.basename(f)[:-5]
72            try:
73                d = tomllib.load(open(f, "rb"))
74            except Exception as e:
75                recs[n] = dict(path=f, parse_error=str(e), queue=queue)
76                continue
77            b = d.get("build", {})
78            recs[n] = dict(
79                path=f,
80                # el artefacto se sella por el `name` INTERNO, que puede diferir del basename del
81                # fichero (qtbase.toml → name="qt6-qtbase"). El store lookup usa iname; las aristas de
82                # deps siguen usando el basename (así resuelve `resolve_dep_path`).
83                iname=d.get("name") or n,
84                version=d.get("version"),
85                link=b.get("link"),
86                compiler=b.get("compiler"),
87                deps=d.get("deps", {}).get("build", []),
88                cls=classify(n, d),
89                queue=queue,
90            )
91    return recs
92
93def vigente_hash(path):
94    try:
95        out = subprocess.run([HAMMER, "--store", STORE, "hash", path],
96                             capture_output=True, text=True, timeout=60)
97        h = out.stdout.strip()
98        return h if h.startswith("b3:") else None
99    except Exception:
100        return None
101
102def state_of(iname, h):
103    if h is None:
104        return "unhashable"
105    if os.path.isdir(os.path.join(STORE, f"{h[3:]}-{iname}")):
106        return "sealed"
107    # ¿hay algún sellado histórico? distingue "cambió" de "nunca construida".
108    if glob.glob(os.path.join(STORE, f"*-{iname}")):
109        return "debt"
110    return "never"
111
112def toposort(nodes):
113    # Kahn sobre las aristas name→dep (sólo deps dentro del corpus). Devuelve orden o None si hay ciclo.
114    indeg = {n: 0 for n in nodes}
115    adj = {n: [] for n in nodes}
116    for n, r in nodes.items():
117        for dep in r.get("deps", []):
118            if dep in nodes:
119                adj[dep].append(n); indeg[n] += 1
120    q = sorted([n for n, d in indeg.items() if d == 0])
121    order = []
122    while q:
123        n = q.pop(0); order.append(n)
124        for m in sorted(adj[n]):
125            indeg[m] -= 1
126            if indeg[m] == 0: q.append(m)
127    return order if len(order) == len(nodes) else None
128
129def main():
130    recs = load_recipes()
131    orphans = set()
132    for n, r in recs.items():
133        for dep in r.get("deps", []):
134            if dep not in recs:
135                orphans.add(dep)
136
137    print(f"== {len(recs)} recetas; computando hash vigente (~{len(recs)*18//1000}s)…", file=sys.stderr)
138    for n, r in recs.items():
139        if "parse_error" in r:
140            r["hash"] = None; r["state"] = "unhashable"; continue
141        h = vigente_hash(r["path"])
142        r["hash"] = h
143        r["state"] = state_of(r.get("iname", n), h)
144
145    order = toposort(recs)
146
147    # Lectura ACCIONABLE del grafo topológico: para cada receta, qué deps la BLOQUEAN (están en
148    # deuda) y a cuántas otras en deuda DESBLOQUEA (la tienen como dep). Una receta en deuda sin
149    # bloqueadores es construible YA; ordenar las listas por `unblocks` da el orden que libera más.
150    DEBT_STATES = ("debt", "never", "unhashable")
151    for n, r in recs.items():
152        r["blocked_by"] = [d for d in r.get("deps", [])
153                           if d in recs and recs[d]["state"] in DEBT_STATES]
154    for n, r in recs.items():
155        # cuántas recetas EN DEUDA me declaran como dep directa (mi impacto de desbloqueo).
156        r["unblocks"] = sum(1 for m, rm in recs.items()
157                            if rm["state"] in DEBT_STATES and n in rm.get("deps", []))
158
159    from collections import Counter
160    by_state = Counter(r["state"] for r in recs.values())
161    by_cls = Counter(r["cls"] for r in recs.values() if "cls" in r)
162    # deuda por clase: dónde están los huecos.
163    debt_by_cls = Counter(r["cls"] for r in recs.values()
164                          if r.get("state") in ("debt", "never") and "cls" in r)
165
166    # Reparto por cola (corpus vs incoming-kde…): dónde está cada cosa.
167    by_queue = {}
168    for r in recs.values():
169        q = r.get("queue", "corpus")
170        by_queue.setdefault(q, Counter())[r["state"]] += 1
171
172    doc = dict(
173        schema="hammer-build-state/1",
174        totals=dict(recipes=len(recs), **by_state),
175        by_class={k: by_cls[k] for k in sorted(by_cls)},
176        debt_by_class={k: debt_by_cls[k] for k in sorted(debt_by_cls)},
177        by_queue={q: dict(c) for q, c in sorted(by_queue.items())},
178        orphan_deps=sorted(orphans),
179        topo_ok=order is not None,
180        nodes={n: {k: v for k, v in r.items() if k != "path"} for n, r in sorted(recs.items())},
181    )
182    OUT.parent.mkdir(parents=True, exist_ok=True)
183    OUT.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n")
184
185    # Resumen humano a stdout.
186    print(f"\n== estado del corpus ({len(recs)} recetas) → {OUT.relative_to(ROOT)}")
187    for s in ("sealed", "debt", "never", "unhashable"):
188        if by_state.get(s):
189            print(f"   {s:11} {by_state[s]}")
190    if debt_by_cls:
191        print("   deuda por clase:", "  ".join(f"{k}={v}" for k, v in sorted(debt_by_cls.items())))
192    if len(by_queue) > 1:
193        for q, c in sorted(by_queue.items()):
194            print(f"   cola {q:14} sellado={c.get('sealed',0):3} deuda={c.get('debt',0):3} "
195                  f"nunca={c.get('never',0):2}")
196    print(f"   grafo: {'CIERRA' if not orphans else f'{len(orphans)} deps huérfanas'} | "
197          f"topo-sort: {'OK' if order else 'CICLO'}")
198    if orphans:
199        print("   huérfanas:", " ".join(sorted(orphans)))
200
201    if CHECK and (orphans or by_state.get("unhashable") or not order):
202        sys.exit(1)
203
204if __name__ == "__main__":
205    main()