tawasuyu

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…

⎇ main scripts/vigia-parches.py Python · 300 líneas · 15.4 KB Historia Quién tocó qué crudo

takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4 476168bb0 · Sergio ·

1#!/usr/bin/env python3
2# vigia-parches.py — ¿los parches de la receta AGARRAN todavía en la fuente que la receta pinea?
3#
4# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
5# `takana build` aplica los parches DESPUÉS de materializar las dependencias. En una receta hoja de
6# la plataforma Gecko eso significa que el `patch` que no agarra se descubre detrás de horas de
7# compilar OTRA COSA: waterfox estrena su primer build reconstruyendo nodejs (4414 objetivos de V8,
8# a `-j2` porque el propio recipe capa por RAM). El parche que falla es el paso 3 de un camino cuyo
9# paso 1 cuesta media noche, y el fallo no dice nada que no se supiera antes de empezar.
10#
11# La receta de waterfox además hace una APUESTA EXPLÍCITA y escrita: la base es Gecko 153.1.0 y los
12# once parches de musl son los de Firefox 154, una release por encima. Su propio comentario dice
13# «si `patch` falla, falla TEMPRANO, antes de compilar nada». Con el orden real de `takana build`
14# eso no es cierto: falla tarde. Este vigía es lo que lo vuelve cierto.
15#
16# ══ Y EL SEGUNDO PUNTO CIEGO, QUE ES EL CARO ═══════════════════════════════════════════════════
17# `patch` no responde sí/no: responde sí, sí-con-desplazamiento y **sí-con-fuzz**. El fuzz es que
18# las líneas de CONTEXTO no coinciden y `patch` decidió igual dónde meter el cambio, adivinando. Con
19# `--silent` —que es como lo llama `fetch.rs`— eso sale por exit 0 y NADIE SE ENTERA. Un parche que
20# entra con fuzz puede haber editado el sitio correcto (y en waterfox los dos que lo hacen editan el
21# sitio correcto: el contexto que no casa son comillas simples vs dobles de un `black`, y un brazo
22# `cfg` de más) o puede haber editado otro. La diferencia no la ve el exit code: hay que MIRARLA.
23#
24# Por eso esto no reporta «✓/✗» sino tres estados, y el del medio es el que existe para no perderse:
25#
26#     ok        entra limpio, sin fuzz. Con desplazamiento está bien: el desplazamiento sólo dice
27#               que el fichero creció por arriba, no que el contexto haya cambiado.
28#     FUZZ      entra, pero adivinando. NO es un fallo y no rompe el build — es la señal de que hay
29#               que abrir el diff y confirmar a mano que el cambio cayó donde debía.
30#     FUZZ✓     entra adivinando, PERO alguien ya abrió el diff y anotó dónde cayó, en
31#               `docs/state/fuzz-verificado.tsv`. La entrada está indexada por una huella del parche
32#               MÁS el pin de la fuente: si cualquiera de los dos cambia, deja de casar y vuelve a
33#               salir como FUZZ. Un fuzz mirado no debería preguntarse en cada corrida —eso vuelve
34#               ruido al vigía y así es como se pierde el FALLA del día que aparezca— pero tampoco
35#               debe poder silenciarse para siempre.
36#     FALLA     no entra. Esto sí es el muro, y es el que queremos ver en dos minutos y no en cuatro
37#               horas. El arreglo es rebasar el parche que se queje, no bajar el listón del vigía.
38#
39# ══ POR QUÉ ES BARATO, QUE ES LO QUE LO HACE USABLE ════════════════════════════════════════════
40# No baja el árbol. Saca de los propios parches los ficheros que tocan —21 en el caso de waterfox—
41# y para una fuente git hace `--filter=blob:none` + sparse-checkout de ESOS. Un árbol Gecko entero
42# se vuelve dos docenas de blobs. Para un tarball no hay atajo equivalente, así que se baja una vez
43# y se extrae sólo lo que hace falta; sigue siendo órdenes de magnitud menos que construir.
44#
45# ══ SE APLICAN ACUMULATIVOS, COMO EN EL BUILD DE VERDAD ════════════════════════════════════════
46# `fetch.rs::apply_patches` recorre `source.patches` EN ORDEN sobre el mismo árbol, con
47# `patch -p1 --batch --silent`. Probar cada parche por separado contra el árbol virgen da el mismo
48# resultado sólo mientras no haya dos que toquen el mismo fichero — y eso es una propiedad de hoy,
49# no una garantía. Así que acá se aplican de verdad, uno tras otro, en el mismo árbol y en el mismo
50# orden, y se conserva `-p1 --batch`. Lo único que se agrega es `--verbose`, para poder LEER lo que
51# `--silent` calla.
52#
53# Uso:
54#   scripts/vigia-parches.py recipes/waterfox.toml
55#   scripts/vigia-parches.py --all            # las ~45 recetas del árbol que declaran parches
56#   scripts/vigia-parches.py --all --git-only # sólo fuentes git (baratas); saltea los tarballs
57#   scripts/vigia-parches.py recipes/x.toml --keep   # deja el árbol para inspeccionar el diff
58#
59# Exit: 0 si todos los parches entran. 1 si alguno FALLA. Con `--strict`, también 1 si alguno
60# entra con FUZZ — que es lo que se quiere en CI, y no lo que se quiere mirando a mano.
61
62import argparse
63import os
64import hashlib
65import re
66import shutil
67import subprocess
68import sys
69import tarfile
70import tempfile
71import tomllib
72import urllib.request
73from pathlib import Path
74
75ROOT = Path(__file__).resolve().parent.parent
76
77# `patch` reporta el desplazamiento y el fuzz por hunk, en stdout, sólo con --verbose.
78RE_HUNK = re.compile(r"^Hunk #(\d+) (succeeded|FAILED) at (\d+)(.*)$", re.M)
79RE_FUZZ = re.compile(r"with fuzz (\d+)")
80
81# ── EL LIBRO DE FUZZ YA MIRADOS ────────────────────────────────────────────────────────────────
82# Un FUZZ no se puede resolver automáticamente: hay que abrir el diff y ver si el cambio cayó donde
83# debía. Pero una vez mirado, el veredicto NO caduca solo — depende de dos cosas y las dos están
84# pineadas: el TEXTO del parche y la FUENTE. Repreguntarlo en cada corrida convierte al vigía en
85# ruido y se acaba ignorando (que es como se pierde el FALLA del día que aparezca).
86#
87# Por eso el libro se indexa por una HUELLA de las dos cosas. Si alguien toca el parche, o sube la
88# versión de la receta, la huella cambia, la entrada deja de casar y el vigía vuelve a preguntar.
89# Es lo contrario de una lista de excepciones: no hay forma de silenciar algo y que se quede
90# silenciado cuando cambió.
91LIBRO = Path(__file__).resolve().parent.parent / "docs/state/fuzz-verificado.tsv"
92
93
94def huella(texto_parche: str, src: dict) -> str:
95    """Identidad de un (parche, fuente) pineados. Cambiá cualquiera de los dos y cambia."""
96    pin = src.get("sha256") or src.get("commit") or ""
97    h = hashlib.sha256()
98    h.update(texto_parche.encode())
99    h.update(b"\n")
100    h.update(pin.encode())
101    return h.hexdigest()[:16]
102
103
104def cargar_libro() -> dict:
105    if not LIBRO.exists():
106        return {}
107    out = {}
108    for linea in LIBRO.read_text(encoding="utf8").splitlines():
109        if linea.startswith("#") or not linea.strip():
110            continue
111        campos = linea.split("\t")
112        if len(campos) >= 4:
113            out[(campos[0], campos[1], campos[2])] = campos[3]
114    return out
115RE_OFFSET = re.compile(r"offset (-?\d+) lines?")
116
117
118def ficheros_del_parche(texto: str) -> list[str]:
119    """Los caminos que el parche toca, tal como los verá `patch -p1`.
120
121    Se leen del CUERPO (`---`/`+++`), nunca del diffstat de la cabecera: un parche recortado a mano
122    conserva el diffstat del commit original y entonces la cabecera MIENTE. Pasa hoy mismo —
123    `firefox-patches/time64.patch` anuncia tres ficheros y entrega dos (le falta el hunk de
124    `wgpu-hal`), y como firefox 154 sella igual, nadie lo había notado.
125
126    Se descarta SIEMPRE el primer componente, se llame como se llame, porque eso es exactamente lo
127    que significa el `-p1` con el que `fetch.rs` los aplica. Suponer el prefijo `a/`+`b/` de git es
128    un error: `echild-strerror.patch` (gawk) viene de un `diff -upr` a secas y sus caminos empiezan
129    en `gawk-5.1.0.orig/` y `gawk-5.1.0/`. Con la suposición de git ese parche daba «can't find file
130    to patch» sobre una receta que sella perfecto — o sea, el vigía mintiendo en la dirección cara.
131    """
132    out = []
133    for m in re.finditer(r"^(?:---|\+\+\+) ([^\s\t]+)", texto, re.M):
134        p = m.group(1)
135        if p == "/dev/null":
136            continue
137        if "/" in p:
138            out.append(p.split("/", 1)[1])
139    return sorted(set(out))
140
141
142def traer_git(repo: str, commit: str, ficheros: list[str], dest: Path) -> None:
143    """Sparse-checkout de sólo `ficheros` en `commit`. No trae historia ni blobs de más."""
144    run = lambda *a: subprocess.run(a, cwd=dest, check=True,
145                                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
146    run("git", "init", "-q", ".")
147    run("git", "remote", "add", "origin", repo)
148    run("git", "config", "core.sparseCheckout", "true")
149    subprocess.run(["git", "fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", commit],
150                   cwd=dest, check=True)
151    (dest / ".git" / "info" / "sparse-checkout").write_text(
152        "".join(f"/{f}\n" for f in ficheros), encoding="utf8")
153    run("git", "checkout", "-q", "FETCH_HEAD")
154
155
156def traer_tarball(url: str, ficheros: list[str], dest: Path) -> None:
157    """Sin atajo posible: se baja el tarball y se extraen sólo los miembros que hacen falta.
158
159    El primer componente del camino se descarta (equivale al `-p1` con el que se aplican después),
160    igual que hace hammer al extraer.
161    """
162    quiero = set(ficheros)
163    with tempfile.NamedTemporaryFile(suffix=".tar", delete=True) as tmp:
164        with urllib.request.urlopen(url) as r:
165            shutil.copyfileobj(r, tmp)
166        tmp.flush()
167        with tarfile.open(tmp.name, "r:*") as tf:
168            for m in tf:
169                if not m.isfile():
170                    continue
171                rel = m.name.split("/", 1)[1] if "/" in m.name else m.name
172                if rel not in quiero:
173                    continue
174                destino = dest / rel
175                destino.parent.mkdir(parents=True, exist_ok=True)
176                src = tf.extractfile(m)
177                if src:
178                    destino.write_bytes(src.read())
179
180
181def probar(receta: Path, keep: bool, git_only: bool, libro: dict) -> tuple[str, list[str]]:
182    """Devuelve (estado, líneas del informe) para una receta. Estado ∈ ok|fuzz|falla|saltada."""
183    datos = tomllib.loads(receta.read_text(encoding="utf8"))
184    src = datos.get("source", {})
185    parches = src.get("patches") or []
186    nombre = datos.get("name", receta.stem)
187    if not parches:
188        return "saltada", [f"{nombre}: sin parches"]
189
190    textos = {}
191    ficheros: set[str] = set()
192    for p in parches:
193        ruta = receta.parent / p
194        if not ruta.exists():
195            return "falla", [f"{nombre}: ✗ el parche declarado no existe en el disco: {p}"]
196        t = ruta.read_text(encoding="utf8", errors="replace")
197        textos[p] = t
198        ficheros.update(ficheros_del_parche(t))
199
200    es_git = "repo" in src
201    if not es_git and git_only:
202        return "saltada", [f"{nombre}: tarball, salteada por --git-only"]
203
204    tmp = Path(tempfile.mkdtemp(prefix=f"vigia-parches-{nombre}-"))
205    try:
206        if es_git:
207            commit = src.get("commit")
208            if not commit or len(commit) < 40:
209                # Un tag flotante no es un pin (ADR 0006); tampoco se puede verificar contra él.
210                return "falla", [f"{nombre}: ✗ `commit` no es un sha completo: {commit!r}"]
211            traer_git(src["repo"], commit, sorted(ficheros), tmp)
212        else:
213            url = src.get("tarball") or src.get("url")
214            if not url:
215                return "saltada", [f"{nombre}: [source] sin `repo` ni `tarball`"]
216            traer_tarball(url, sorted(ficheros), tmp)
217
218        lineas = [f"{nombre} ({datos.get('version','?')}) — {len(parches)} parches, "
219                  f"{len(ficheros)} ficheros tocados"]
220        peor = "ok"
221        # Acumulativos y en orden, igual que fetch.rs::apply_patches.
222        for p in parches:
223            r = subprocess.run(["patch", "-p1", "--batch", "--verbose"],
224                               cwd=tmp, input=textos[p], text=True,
225                               stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
226            hunks = RE_HUNK.findall(r.stdout)
227            fuzz = [(h[0], RE_FUZZ.search(h[3]).group(1)) for h in hunks if RE_FUZZ.search(h[3])]
228            offs = [h[0] for h in hunks if RE_OFFSET.search(h[3])]
229            base = os.path.basename(p)
230            if r.returncode != 0:
231                peor = "falla"
232                lineas.append(f"   FALLA  {base}")
233                for l in r.stdout.splitlines():
234                    if "FAILED" in l or "can't find file" in l or "Reversed" in l:
235                        lineas.append(f"          {l.strip()}")
236            elif fuzz:
237                det = ", ".join(f"hunk #{h} fuzz {f}" for h, f in fuzz)
238                hu = huella(textos[p], src)
239                visto = libro.get((nombre, base, hu))
240                if visto:
241                    lineas.append(f"   FUZZ✓  {base}  ({det}) — ya mirado: {visto}")
242                else:
243                    if peor != "falla":
244                        peor = "fuzz"
245                    lineas.append(f"   FUZZ   {base}  ({det}) — entra ADIVINANDO, mirá el diff")
246                    # La línea lista para pegar en el libro DESPUÉS de mirarlo. Se da acá porque
247                    # calcular la huella a mano es justo la fricción que hace que nadie lo anote.
248                    lineas.append(f"          libro: {nombre}\t{base}\t{hu}\t<tu veredicto>")
249            else:
250                extra = f"  ({len(offs)} hunks desplazados)" if offs else ""
251                lineas.append(f"   ok     {base}{extra}")
252        if keep:
253            lineas.append(f"   árbol parcheado en {tmp}")
254        return peor, lineas
255    finally:
256        if not keep:
257            shutil.rmtree(tmp, ignore_errors=True)
258
259
260def main() -> int:
261    ap = argparse.ArgumentParser(description="¿los parches de una receta siguen agarrando?")
262    ap.add_argument("recipes", nargs="*", type=Path)
263    ap.add_argument("--all", action="store_true", help="todas las recetas que declaren parches")
264    ap.add_argument("--git-only", action="store_true", help="saltear fuentes tarball (más caras)")
265    ap.add_argument("--keep", action="store_true", help="no borrar el árbol parcheado")
266    ap.add_argument("--strict", action="store_true", help="salir 1 también si algo entra con fuzz")
267    a = ap.parse_args()
268
269    objetivos = list(a.recipes)
270    if a.all:
271        for f in sorted((ROOT / "recipes").glob("*.toml")):
272            if re.search(r"^\s*patches\s*=", f.read_text(encoding="utf8", errors="replace"), re.M):
273                objetivos.append(f)
274    if not objetivos:
275        ap.error("dame una receta o --all")
276
277    peor = "ok"
278    libro = cargar_libro()
279    orden = {"saltada": 0, "ok": 1, "fuzz": 2, "falla": 3}
280    for r in objetivos:
281        try:
282            estado, lineas = probar(r, a.keep, a.git_only, libro)
283        except subprocess.CalledProcessError as e:
284            estado, lineas = "falla", [f"{r}: ✗ no pude traer la fuente ({e})"]
285        except Exception as e:  # una receta rota no debe tumbar el barrido entero
286            estado, lineas = "falla", [f"{r}: ✗ {type(e).__name__}: {e}"]
287        print("\n".join(lineas))
288        print()
289        if orden[estado] > orden[peor]:
290            peor = estado
291
292    if peor == "falla":
293        return 1
294    if peor == "fuzz" and a.strict:
295        return 1
296    return 0
297
298
299if __name__ == "__main__":
300    sys.exit(main())