El bug que declaró `busybox` en 29 recetas y rompió binutils: harkaq-suggest veía `/bin/busybox` denegado, encontraba `recipes/busybox.toml` en el store y decía "declarar dep: busybox" — sin mirar que la política YA lo concede (`ro /bin/busybox`: el sandbox corre `sh -c` y /bin/sh→busybox ⇒ es CONTRATO, no dep). Fix: harkaq-suggest ahora lee los paths CONCEDIDOS de la política (`ro`/`rw`/`list`), no sólo los `# expect`. Si un path está concedido ⇒ categoría RUNTIME BASE: no se declara, aunque el store lo provea. Las 4 clasificaciones verificadas tras el cambio: /bin/busybox → RUNTIME BASE (ya concedido; NO declarar) ← el fix /usr/bin/make → DECLARABLE (declarar dep: make) /usr/lib/libstdc++.so → IRREDUCIBLE (nadie lo provee) /usr/bin/gcc en broot → DEUDA DE COMPILADOR (compiler=gcc) /usr/bin/gcc en zlib → sin deuda (sonda esperada; zlib compila con zig) Con esto puesto, busybox JAMÁS se habría declarado. Cierra el TODO que dejó el revert: la campaña automática ya no puede cimentar contrato como dependencia. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
8.8 KiB
Python
Executable File
185 lines
8.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""harkaq-suggest — convierte la deuda en una acción concreta (SDD 16 §4.5).
|
|
|
|
harkaq-suggest.py <verdict-crudo.json> <politica> <store> [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 <dep>` funde `<dep>/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>-<name>` → `<name>`."""
|
|
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 hammer 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 hammer 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())
|