#!/usr/bin/env python3 """harkaq-suggest — convierte la deuda en una acción concreta (SDD 16 §4.5). harkaq-suggest.py [recipes-dir] Un veredicto `Impuro` dice *qué* usó el build sin declarar. Esto dice *qué hacer*: por cada path de deuda busca en el store un artefacto sellado que provea ese mismo path relativo. Si lo hay, la deuda es **declarable** — la receta sólo tiene que añadir la dep, y hammer ya sabe construirla. La distinción importa para la métrica de la Fase 2 (<5% de recetas necesitan excepción): declarable el store ya lo provee ⇒ NO es una excepción, es una línea en `[deps]`. El diagnóstico es accionable y el arreglo, mecánico. irreducible nadie lo provee ⇒ o se escribe una receta, o se acepta como runtime base. Ésta es la que cuenta contra el <5%. Ejemplo real: zlib salió `Impuro` por `/usr/bin/make`. Parecía runtime base — pero `recipes/make.toml` existe y `store/…-make` está sellado, y `SWAP_MAKE` es uno de los swaps del selfhost-verify. O sea: no es base, es deuda declarable. La diferencia entre "aceptamos que hammer usa el make de Alpine" y "a zlib le falta una línea" la decide este cruce, no una opinión. """ import json import os import sys def indice_del_store(store): """path-dentro-del-sandbox → [artefactos que lo proveen]. `--overlay-src ` funde `/usr/...` en `/usr/...`, así que el path relativo dentro del artefacto ES el path del sandbox. El mismo mapeo que usa harkaq-policy, al revés. """ idx = {} for art in sorted(os.listdir(store)): raiz = os.path.join(store, art) if not os.path.isdir(raiz): continue for dirpath, _, files in os.walk(raiz): if "/.hammer" in dirpath: continue for f in files: completo = os.path.join(dirpath, f) dentro = "/" + os.path.relpath(completo, raiz) idx.setdefault(dentro, []).append(art) return idx def nombre_de(art): """`<64hex>-` → ``.""" return art.split("-", 1)[1] if "-" in art else art def catalogo(recipes_dir): """Nombres de receta que hammer SABE construir, tenga o no el artefacto a mano. Sin esto, "declarable" sería relativo a la máquina: el store del worker de la granja trae 103 artefactos y el del laptop cientos, así que el MISMO path (`/usr/bin/make`) salía declarable en uno e irreducible en el otro. Eso no es una propiedad de la receta: es del disco donde corrés el barrido, y habría inflado la métrica de la Fase 2 en la granja — que es justo donde se va a medir en serio. El catálogo es la respuesta correcta a "¿esto lo sabemos construir?". El store sólo dice "¿lo tenemos construido AHORA?", que es otra pregunta. """ if not recipes_dir or not os.path.isdir(recipes_dir): return set() return {f[:-5] for f in os.listdir(recipes_dir) if f.endswith(".toml")} # Los frontends del gcc de Alpine. Son "sonda esperada" (D3) SÓLO cuando la receta compila con # zig (el default): el configure los sondea y zig no los usa. Pero para una receta `compiler=gcc` # (`CC=gcc`, el gcc de Alpine), gcc ES el compilador y su uso es DEUDA DE COMPILADOR — el frente # matar-gcc. Sin esta distinción, harkaq marcaba `Hermetico` recetas que dependen del gcc de # Alpine (47 de ellas), ciego a la deuda mayor. SDD 16, límite del modelo (2026-07-16). FRONTENDS_GCC = {"/usr/bin/gcc", "/usr/bin/cpp", "/usr/bin/c89", "/usr/bin/c99", "/usr/bin/g++", "/usr/bin/cc", "/usr/bin/ld", "/usr/bin/as"} def compiler_de(recipe_toml): """`gcc` si la receta usa el gcc de Alpine como compilador, si no `zig`.""" try: s = open(recipe_toml).read() except OSError: return "zig" import re m = re.search(r'compiler\s*=\s*"([^"]+)"', s) return "gcc" if m and m.group(1) == "gcc" else "zig" def main(): if len(sys.argv) < 4: print(__doc__.split("\n")[2].strip(), file=sys.stderr) return 2 crudo = json.load(open(sys.argv[1])) politica, store = sys.argv[2], sys.argv[3] recipes_dir = sys.argv[4] if len(sys.argv) > 4 else os.path.join( os.path.dirname(os.path.abspath(store)), "recipes") # 5º arg opcional: la receta que se midió. Si usa compiler=gcc, los frontends de gcc dejan de # ser "esperada" y pasan a DEUDA DE COMPILADOR (matar-gcc). receta_toml = sys.argv[5] if len(sys.argv) > 5 else None usa_gcc = receta_toml and compiler_de(receta_toml) == "gcc" esperadas = set() # RUNTIME BASE: lo que la política ya CONCEDE (`ro`/`rw`/`list`). Si un path está acá, NO es # deuda declarable aunque el store lo provea — es contrato del sandbox. # # Este bug costó caro: `/bin/busybox` está concedido (el sandbox corre `sh -c` y /bin/sh→ # busybox), pero como `recipes/busybox.toml` existe, el clasificador propuso "declarar dep: # busybox" y la cosecha lo declaró en 29 recetas. Declararlo apila el busybox de takana sobre # el de Alpine y ROMPE el build (binutils: "cannot run C compiled programs"). Además va contra # la Etapa C, que reemplaza busybox por uutils. Medir bien y accionar mal. concedidos = set() with open(politica) as f: for l in f: l = l.strip() if l.startswith("# expect "): esperadas.add(l[len("# expect "):].strip()) elif l[:3] in ("ro ", "rw ") or l.startswith("list "): concedidos.add(l.split(None, 1)[1].strip()) # Si la receta compila con gcc, los frontends NO se perdonan: son la deuda de compilador. if usa_gcc: esperadas -= FRONTENDS_GCC deuda = sorted({d["path"] for d in crudo.get("denials", []) if d["path"] not in esperadas}) if not deuda: print("sin deuda ✓") return 0 idx = indice_del_store(store) recetas = catalogo(recipes_dir) declarable, irreducible, compilador, base = {}, [], [], [] for p in deuda: if usa_gcc and p in FRONTENDS_GCC: compilador.append(p) continue # ¿Ya concedido por la política? ⇒ runtime base, NO se declara (ver arriba: busybox). if p in concedidos or any(p.startswith(c.rstrip("/") + "/") for c in concedidos if c != "/"): base.append(p) continue provs = idx.get(p) if provs: # El store es autoritativo: sabe qué ficheros aporta cada artefacto. declarable[p] = sorted({nombre_de(a) for a in provs}) elif os.path.basename(p) in recetas: # Respaldo por catálogo: el artefacto no está construido AQUÍ, pero takana sabe # construirlo. Es declarable igual — la deuda es de la receta, no del disco. # # OJO, heurística por NOMBRE y por eso sólo un respaldo: acierta con `make`, pero # falla con `/usr/bin/ranlib` (lo trae `binutils`) y `/usr/bin/diff` (lo trae # `diffutils`). Un fichero no se llama como su paquete. El único que sabe la verdad # es el store, porque conoce la lista de ficheros de cada artefacto. # # ⇒ CLASIFICAR EN EL HUB, no en el worker. El worker de la granja mide (produce los # veredictos crudos) y el hub clasifica, porque es quien tiene el store COMPLETO. Con # el store parcial del worker (103 artefactos) esto marcaba bash y doas como deuda # IRREDUCIBLE y habría dado una métrica de Fase 2 inflada. Misma separación que # lector/clasificador: el que tiene el privilegio (o los datos) hace lo mínimo. declarable[p] = [os.path.basename(p) + " (receta en el catálogo, sin construir aquí)"] else: irreducible.append(p) if base: print("RUNTIME BASE — ya concedido por la política; NO declarar (es contrato del sandbox):") for p in base: print(f" {p}") if compilador: print("DEUDA DE COMPILADOR (matar-gcc) — la receta usa el gcc de Alpine (compiler=gcc):") for p in compilador: print(f" {p} → migrar a zig-cc, o aceptar como escape declarativo documentado") if declarable: print("DEUDA DECLARABLE — el store ya lo provee; falta la línea en [deps]:") for p, quien in declarable.items(): print(f" {p}\n → declarar dep: {', '.join(quien)}") if irreducible: print("DEUDA IRREDUCIBLE — nadie la provee (escribir receta, o aceptar como runtime base):") for p in irreducible: print(f" {p}") # El exit code separa "arreglable con una línea" de "hace falta trabajo de verdad": es lo que # un barrido de la Fase 2 necesita para contar contra el <5% sin leer texto. return 0 if not irreducible else 1 if __name__ == "__main__": sys.exit(main())