#!/usr/bin/env python3
# build-state-view.py — vista HUMANA del grafo de estado. Lee docs/state/build-state.json (la fuente
# firme que emite build-state.py) y emite docs/state/build-state.html: un dashboard self-contained
# (sin recursos externos) para VER y SEGUIR el avance, los huecos y los fallos.
#
# El dato clave que añade sobre el JSON crudo: para cada receta en deuda, computa quién la BLOQUEA
# —sus deps que también están en deuda— usando el grafo. Una receta en deuda con TODAS sus deps
# selladas está "lista para construir ya"; una con deps en deuda debe esperar. Ese es el uso
# accionable del orden topológico: qué atacar primero.
#
# Uso: scripts/build-state-view.py # regenera docs/state/build-state.html desde el JSON
import json, html
from pathlib import Path
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "docs/state/build-state.json"
OUT = ROOT / "docs/state/build-state.html"
doc = json.loads(DATA.read_text())
nodes = doc["nodes"]
STATE_ORDER = ["sealed", "debt", "never", "unhashable"]
STATE_ES = {"sealed": "sellado al día", "debt": "deuda de rebuild",
"never": "nunca construida", "unhashable": "ilegible"}
CLASS_ES = {"c": "C", "go": "Go", "rust": "Rust", "gui": "GUI", "kernel": "kernel"}
# blocked_by / unblocks los computa build-state.py sobre el grafo; acá sólo los leemos.
def blockers(name):
return nodes[name].get("blocked_by", [])
def unblocks(name):
return nodes[name].get("unblocks", 0)
debt = {n: r for n, r in nodes.items() if r["state"] in ("debt", "never", "unhashable")}
# Orden de ATAQUE: primero las "listas" (sin bloqueadores), y entre ellas la que DESBLOQUEA más
# arriba; luego las bloqueadas por menos, luego alfabético. Es el orden que libera el grafo más rápido.
def debt_key(n):
b = blockers(n)
return (len(b), -unblocks(n), nodes[n]["cls"], n)
debt_sorted = sorted(debt, key=debt_key)
ready = [n for n in debt_sorted if not blockers(n)]
totals = doc["totals"]
n_total = totals["recipes"]
# Reparto por clase (para las barras por-clase).
classes = {}
for n, r in nodes.items():
c = r.get("cls", "c")
classes.setdefault(c, {s: 0 for s in STATE_ORDER})
classes[c][r["state"]] += 1
def pct(x): return f"{100*x/n_total:.1f}%"
E = html.escape
def chip(dep):
st = nodes[dep]["state"] if dep in nodes else "unhashable"
return f'{E(dep)}'
# ---- barra de progreso apilada (sealed|debt|never|unhashable) ----
seg = ""
for s in STATE_ORDER:
v = totals.get(s, 0)
if v:
seg += f''
# ---- tiles de estado ----
tiles = ""
for s in STATE_ORDER:
v = totals.get(s, 0)
tiles += f'''
{v}
{E(STATE_ES[s])}
{pct(v)}
'''
# ---- grid por clase ----
cls_cards = ""
for c in sorted(classes, key=lambda c: -sum(classes[c].values())):
cc = classes[c]; tot = sum(cc.values())
bar = ""
for s in STATE_ORDER:
if cc[s]:
bar += f''
deuda = cc["debt"] + cc["never"] + cc["unhashable"]
cls_cards += f'''
{E(CLASS_ES.get(c, c))}{tot}
{bar}
{cc['sealed']} al día{deuda} en deuda
'''
# ---- filas de deuda (agrupadas visualmente por "listas" vs "bloqueadas") ----
def debt_row(n):
r = nodes[n]; b = blockers(n); u = unblocks(n)
deps = r.get("deps", [])
chips = "".join(chip(d) for d in deps) if deps else 'sin deps de build'
if not b:
impact = f' ↑{u}' if u else ""
badge = f'lista{impact}'
else:
badge = f'espera {len(b)}'
ver = f'{E(str(r.get("version") or ""))}' if r.get("version") else ""
return f'''
{totals.get('sealed',0)} sellado al día{totals.get('debt',0)} deuda de rebuild{totals.get('never',0)} nunca construida
{''+str(totals.get('unhashable',0))+' ilegible' if totals.get('unhashable') else ''}
{tiles}
dónde está la deuda
{cls_cards}
huecos, en orden de ataque
Cada receta en deuda con sus deps de build coloreadas por estado. Una fila
lista tiene todas sus deps al día ⇒ se puede construir ya;
el pastilla ↑N es cuántas otras en deuda desbloquea (ordenadas por
ahí: construí primero las de más impacto). Una espera N depende
de N recetas que también están en deuda. {len(ready)} listas ahora.