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…

TOML 1184Shell 70Rust 62Markdown 501503 Definiciones

⎇ 5db1c7ec89d78a625b2ea331b91329e2f039951f scripts/vigia-parches.py Python · 249 líneas · 12.6 KB Historia Quién tocó qué crudo

vigía de parches: probar que agarran ANTES de las cuatro horas de build aa200a4e7 · 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# `hammer 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 `hammer 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#     FALLA     no entra. Esto sí es el muro, y es el que queremos ver en dos minutos y no en cuatro
31#               horas. El arreglo es rebasar el parche que se queje, no bajar el listón del vigía.
32#
33# ══ POR QUÉ ES BARATO, QUE ES LO QUE LO HACE USABLE ════════════════════════════════════════════
34# No baja el árbol. Saca de los propios parches los ficheros que tocan —21 en el caso de waterfox—
35# y para una fuente git hace `--filter=blob:none` + sparse-checkout de ESOS. Un árbol Gecko entero
36# se vuelve dos docenas de blobs. Para un tarball no hay atajo equivalente, así que se baja una vez
37# y se extrae sólo lo que hace falta; sigue siendo órdenes de magnitud menos que construir.
38#
39# ══ SE APLICAN ACUMULATIVOS, COMO EN EL BUILD DE VERDAD ════════════════════════════════════════
40# `fetch.rs::apply_patches` recorre `source.patches` EN ORDEN sobre el mismo árbol, con
41# `patch -p1 --batch --silent`. Probar cada parche por separado contra el árbol virgen da el mismo
42# resultado sólo mientras no haya dos que toquen el mismo fichero — y eso es una propiedad de hoy,
43# no una garantía. Así que acá se aplican de verdad, uno tras otro, en el mismo árbol y en el mismo
44# orden, y se conserva `-p1 --batch`. Lo único que se agrega es `--verbose`, para poder LEER lo que
45# `--silent` calla.
46#
47# Uso:
48#   scripts/vigia-parches.py recipes/waterfox.toml
49#   scripts/vigia-parches.py --all            # las ~45 recetas del árbol que declaran parches
50#   scripts/vigia-parches.py --all --git-only # sólo fuentes git (baratas); saltea los tarballs
51#   scripts/vigia-parches.py recipes/x.toml --keep   # deja el árbol para inspeccionar el diff
52#
53# Exit: 0 si todos los parches entran. 1 si alguno FALLA. Con `--strict`, también 1 si alguno
54# entra con FUZZ — que es lo que se quiere en CI, y no lo que se quiere mirando a mano.
55
56import argparse
57import os
58import re
59import shutil
60import subprocess
61import sys
62import tarfile
63import tempfile
64import tomllib
65import urllib.request
66from pathlib import Path
67
68ROOT = Path(__file__).resolve().parent.parent
69
70# `patch` reporta el desplazamiento y el fuzz por hunk, en stdout, sólo con --verbose.
71RE_HUNK = re.compile(r"^Hunk #(\d+) (succeeded|FAILED) at (\d+)(.*)$", re.M)
72RE_FUZZ = re.compile(r"with fuzz (\d+)")
73RE_OFFSET = re.compile(r"offset (-?\d+) lines?")
74
75
76def ficheros_del_parche(texto: str) -> list[str]:
77    """Los caminos que el parche toca, tal como los verá `patch -p1`.
78
79    Se leen del CUERPO (`---`/`+++`), nunca del diffstat de la cabecera: un parche recortado a mano
80    conserva el diffstat del commit original y entonces la cabecera MIENTE. Pasa hoy mismo —
81    `firefox-patches/time64.patch` anuncia tres ficheros y entrega dos (le falta el hunk de
82    `wgpu-hal`), y como firefox 154 sella igual, nadie lo había notado.
83
84    Se descarta SIEMPRE el primer componente, se llame como se llame, porque eso es exactamente lo
85    que significa el `-p1` con el que `fetch.rs` los aplica. Suponer el prefijo `a/`+`b/` de git es
86    un error: `echild-strerror.patch` (gawk) viene de un `diff -upr` a secas y sus caminos empiezan
87    en `gawk-5.1.0.orig/` y `gawk-5.1.0/`. Con la suposición de git ese parche daba «can't find file
88    to patch» sobre una receta que sella perfecto — o sea, el vigía mintiendo en la dirección cara.
89    """
90    out = []
91    for m in re.finditer(r"^(?:---|\+\+\+) ([^\s\t]+)", texto, re.M):
92        p = m.group(1)
93        if p == "/dev/null":
94            continue
95        if "/" in p:
96            out.append(p.split("/", 1)[1])
97    return sorted(set(out))
98
99
100def traer_git(repo: str, commit: str, ficheros: list[str], dest: Path) -> None:
101    """Sparse-checkout de sólo `ficheros` en `commit`. No trae historia ni blobs de más."""
102    run = lambda *a: subprocess.run(a, cwd=dest, check=True,
103                                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
104    run("git", "init", "-q", ".")
105    run("git", "remote", "add", "origin", repo)
106    run("git", "config", "core.sparseCheckout", "true")
107    subprocess.run(["git", "fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", commit],
108                   cwd=dest, check=True)
109    (dest / ".git" / "info" / "sparse-checkout").write_text(
110        "".join(f"/{f}\n" for f in ficheros), encoding="utf8")
111    run("git", "checkout", "-q", "FETCH_HEAD")
112
113
114def traer_tarball(url: str, ficheros: list[str], dest: Path) -> None:
115    """Sin atajo posible: se baja el tarball y se extraen sólo los miembros que hacen falta.
116
117    El primer componente del camino se descarta (equivale al `-p1` con el que se aplican después),
118    igual que hace hammer al extraer.
119    """
120    quiero = set(ficheros)
121    with tempfile.NamedTemporaryFile(suffix=".tar", delete=True) as tmp:
122        with urllib.request.urlopen(url) as r:
123            shutil.copyfileobj(r, tmp)
124        tmp.flush()
125        with tarfile.open(tmp.name, "r:*") as tf:
126            for m in tf:
127                if not m.isfile():
128                    continue
129                rel = m.name.split("/", 1)[1] if "/" in m.name else m.name
130                if rel not in quiero:
131                    continue
132                destino = dest / rel
133                destino.parent.mkdir(parents=True, exist_ok=True)
134                src = tf.extractfile(m)
135                if src:
136                    destino.write_bytes(src.read())
137
138
139def probar(receta: Path, keep: bool, git_only: bool) -> tuple[str, list[str]]:
140    """Devuelve (estado, líneas del informe) para una receta. Estado ∈ ok|fuzz|falla|saltada."""
141    datos = tomllib.loads(receta.read_text(encoding="utf8"))
142    src = datos.get("source", {})
143    parches = src.get("patches") or []
144    nombre = datos.get("name", receta.stem)
145    if not parches:
146        return "saltada", [f"{nombre}: sin parches"]
147
148    textos = {}
149    ficheros: set[str] = set()
150    for p in parches:
151        ruta = receta.parent / p
152        if not ruta.exists():
153            return "falla", [f"{nombre}: ✗ el parche declarado no existe en el disco: {p}"]
154        t = ruta.read_text(encoding="utf8", errors="replace")
155        textos[p] = t
156        ficheros.update(ficheros_del_parche(t))
157
158    es_git = "repo" in src
159    if not es_git and git_only:
160        return "saltada", [f"{nombre}: tarball, salteada por --git-only"]
161
162    tmp = Path(tempfile.mkdtemp(prefix=f"vigia-parches-{nombre}-"))
163    try:
164        if es_git:
165            commit = src.get("commit")
166            if not commit or len(commit) < 40:
167                # Un tag flotante no es un pin (ADR 0006); tampoco se puede verificar contra él.
168                return "falla", [f"{nombre}: ✗ `commit` no es un sha completo: {commit!r}"]
169            traer_git(src["repo"], commit, sorted(ficheros), tmp)
170        else:
171            url = src.get("tarball") or src.get("url")
172            if not url:
173                return "saltada", [f"{nombre}: [source] sin `repo` ni `tarball`"]
174            traer_tarball(url, sorted(ficheros), tmp)
175
176        lineas = [f"{nombre} ({datos.get('version','?')}) — {len(parches)} parches, "
177                  f"{len(ficheros)} ficheros tocados"]
178        peor = "ok"
179        # Acumulativos y en orden, igual que fetch.rs::apply_patches.
180        for p in parches:
181            r = subprocess.run(["patch", "-p1", "--batch", "--verbose"],
182                               cwd=tmp, input=textos[p], text=True,
183                               stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
184            hunks = RE_HUNK.findall(r.stdout)
185            fuzz = [(h[0], RE_FUZZ.search(h[3]).group(1)) for h in hunks if RE_FUZZ.search(h[3])]
186            offs = [h[0] for h in hunks if RE_OFFSET.search(h[3])]
187            base = os.path.basename(p)
188            if r.returncode != 0:
189                peor = "falla"
190                lineas.append(f"   FALLA  {base}")
191                for l in r.stdout.splitlines():
192                    if "FAILED" in l or "can't find file" in l or "Reversed" in l:
193                        lineas.append(f"          {l.strip()}")
194            elif fuzz:
195                if peor != "falla":
196                    peor = "fuzz"
197                det = ", ".join(f"hunk #{h} fuzz {f}" for h, f in fuzz)
198                lineas.append(f"   FUZZ   {base}  ({det}) — entra ADIVINANDO, mirá el diff")
199            else:
200                extra = f"  ({len(offs)} hunks desplazados)" if offs else ""
201                lineas.append(f"   ok     {base}{extra}")
202        if keep:
203            lineas.append(f"   árbol parcheado en {tmp}")
204        return peor, lineas
205    finally:
206        if not keep:
207            shutil.rmtree(tmp, ignore_errors=True)
208
209
210def main() -> int:
211    ap = argparse.ArgumentParser(description="¿los parches de una receta siguen agarrando?")
212    ap.add_argument("recipes", nargs="*", type=Path)
213    ap.add_argument("--all", action="store_true", help="todas las recetas que declaren parches")
214    ap.add_argument("--git-only", action="store_true", help="saltear fuentes tarball (más caras)")
215    ap.add_argument("--keep", action="store_true", help="no borrar el árbol parcheado")
216    ap.add_argument("--strict", action="store_true", help="salir 1 también si algo entra con fuzz")
217    a = ap.parse_args()
218
219    objetivos = list(a.recipes)
220    if a.all:
221        for f in sorted((ROOT / "recipes").glob("*.toml")):
222            if re.search(r"^\s*patches\s*=", f.read_text(encoding="utf8", errors="replace"), re.M):
223                objetivos.append(f)
224    if not objetivos:
225        ap.error("dame una receta o --all")
226
227    peor = "ok"
228    orden = {"saltada": 0, "ok": 1, "fuzz": 2, "falla": 3}
229    for r in objetivos:
230        try:
231            estado, lineas = probar(r, a.keep, a.git_only)
232        except subprocess.CalledProcessError as e:
233            estado, lineas = "falla", [f"{r}: ✗ no pude traer la fuente ({e})"]
234        except Exception as e:  # una receta rota no debe tumbar el barrido entero
235            estado, lineas = "falla", [f"{r}: ✗ {type(e).__name__}: {e}"]
236        print("\n".join(lineas))
237        print()
238        if orden[estado] > orden[peor]:
239            peor = estado
240
241    if peor == "falla":
242        return 1
243    if peor == "fuzz" and a.strict:
244        return 1
245    return 0
246
247
248if __name__ == "__main__":
249    sys.exit(main())