diff --git a/scripts/vigia-parches.py b/scripts/vigia-parches.py new file mode 100755 index 00000000..fb5a39ef --- /dev/null +++ b/scripts/vigia-parches.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# vigia-parches.py — ¿los parches de la receta AGARRAN todavía en la fuente que la receta pinea? +# +# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════ +# `hammer build` aplica los parches DESPUÉS de materializar las dependencias. En una receta hoja de +# la plataforma Gecko eso significa que el `patch` que no agarra se descubre detrás de horas de +# compilar OTRA COSA: waterfox estrena su primer build reconstruyendo nodejs (4414 objetivos de V8, +# a `-j2` porque el propio recipe capa por RAM). El parche que falla es el paso 3 de un camino cuyo +# paso 1 cuesta media noche, y el fallo no dice nada que no se supiera antes de empezar. +# +# La receta de waterfox además hace una APUESTA EXPLÍCITA y escrita: la base es Gecko 153.1.0 y los +# once parches de musl son los de Firefox 154, una release por encima. Su propio comentario dice +# «si `patch` falla, falla TEMPRANO, antes de compilar nada». Con el orden real de `hammer build` +# eso no es cierto: falla tarde. Este vigía es lo que lo vuelve cierto. +# +# ══ Y EL SEGUNDO PUNTO CIEGO, QUE ES EL CARO ═══════════════════════════════════════════════════ +# `patch` no responde sí/no: responde sí, sí-con-desplazamiento y **sí-con-fuzz**. El fuzz es que +# las líneas de CONTEXTO no coinciden y `patch` decidió igual dónde meter el cambio, adivinando. Con +# `--silent` —que es como lo llama `fetch.rs`— eso sale por exit 0 y NADIE SE ENTERA. Un parche que +# entra con fuzz puede haber editado el sitio correcto (y en waterfox los dos que lo hacen editan el +# sitio correcto: el contexto que no casa son comillas simples vs dobles de un `black`, y un brazo +# `cfg` de más) o puede haber editado otro. La diferencia no la ve el exit code: hay que MIRARLA. +# +# Por eso esto no reporta «✓/✗» sino tres estados, y el del medio es el que existe para no perderse: +# +# ok entra limpio, sin fuzz. Con desplazamiento está bien: el desplazamiento sólo dice +# que el fichero creció por arriba, no que el contexto haya cambiado. +# FUZZ entra, pero adivinando. NO es un fallo y no rompe el build — es la señal de que hay +# que abrir el diff y confirmar a mano que el cambio cayó donde debía. +# FALLA no entra. Esto sí es el muro, y es el que queremos ver en dos minutos y no en cuatro +# horas. El arreglo es rebasar el parche que se queje, no bajar el listón del vigía. +# +# ══ POR QUÉ ES BARATO, QUE ES LO QUE LO HACE USABLE ════════════════════════════════════════════ +# No baja el árbol. Saca de los propios parches los ficheros que tocan —21 en el caso de waterfox— +# y para una fuente git hace `--filter=blob:none` + sparse-checkout de ESOS. Un árbol Gecko entero +# se vuelve dos docenas de blobs. Para un tarball no hay atajo equivalente, así que se baja una vez +# y se extrae sólo lo que hace falta; sigue siendo órdenes de magnitud menos que construir. +# +# ══ SE APLICAN ACUMULATIVOS, COMO EN EL BUILD DE VERDAD ════════════════════════════════════════ +# `fetch.rs::apply_patches` recorre `source.patches` EN ORDEN sobre el mismo árbol, con +# `patch -p1 --batch --silent`. Probar cada parche por separado contra el árbol virgen da el mismo +# resultado sólo mientras no haya dos que toquen el mismo fichero — y eso es una propiedad de hoy, +# no una garantía. Así que acá se aplican de verdad, uno tras otro, en el mismo árbol y en el mismo +# orden, y se conserva `-p1 --batch`. Lo único que se agrega es `--verbose`, para poder LEER lo que +# `--silent` calla. +# +# Uso: +# scripts/vigia-parches.py recipes/waterfox.toml +# scripts/vigia-parches.py --all # las ~45 recetas del árbol que declaran parches +# scripts/vigia-parches.py --all --git-only # sólo fuentes git (baratas); saltea los tarballs +# scripts/vigia-parches.py recipes/x.toml --keep # deja el árbol para inspeccionar el diff +# +# Exit: 0 si todos los parches entran. 1 si alguno FALLA. Con `--strict`, también 1 si alguno +# entra con FUZZ — que es lo que se quiere en CI, y no lo que se quiere mirando a mano. + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# `patch` reporta el desplazamiento y el fuzz por hunk, en stdout, sólo con --verbose. +RE_HUNK = re.compile(r"^Hunk #(\d+) (succeeded|FAILED) at (\d+)(.*)$", re.M) +RE_FUZZ = re.compile(r"with fuzz (\d+)") +RE_OFFSET = re.compile(r"offset (-?\d+) lines?") + + +def ficheros_del_parche(texto: str) -> list[str]: + """Los caminos que el parche toca, tal como los verá `patch -p1`. + + Se leen del CUERPO (`---`/`+++`), nunca del diffstat de la cabecera: un parche recortado a mano + conserva el diffstat del commit original y entonces la cabecera MIENTE. Pasa hoy mismo — + `firefox-patches/time64.patch` anuncia tres ficheros y entrega dos (le falta el hunk de + `wgpu-hal`), y como firefox 154 sella igual, nadie lo había notado. + + Se descarta SIEMPRE el primer componente, se llame como se llame, porque eso es exactamente lo + que significa el `-p1` con el que `fetch.rs` los aplica. Suponer el prefijo `a/`+`b/` de git es + un error: `echild-strerror.patch` (gawk) viene de un `diff -upr` a secas y sus caminos empiezan + en `gawk-5.1.0.orig/` y `gawk-5.1.0/`. Con la suposición de git ese parche daba «can't find file + to patch» sobre una receta que sella perfecto — o sea, el vigía mintiendo en la dirección cara. + """ + out = [] + for m in re.finditer(r"^(?:---|\+\+\+) ([^\s\t]+)", texto, re.M): + p = m.group(1) + if p == "/dev/null": + continue + if "/" in p: + out.append(p.split("/", 1)[1]) + return sorted(set(out)) + + +def traer_git(repo: str, commit: str, ficheros: list[str], dest: Path) -> None: + """Sparse-checkout de sólo `ficheros` en `commit`. No trae historia ni blobs de más.""" + run = lambda *a: subprocess.run(a, cwd=dest, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + run("git", "init", "-q", ".") + run("git", "remote", "add", "origin", repo) + run("git", "config", "core.sparseCheckout", "true") + subprocess.run(["git", "fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", commit], + cwd=dest, check=True) + (dest / ".git" / "info" / "sparse-checkout").write_text( + "".join(f"/{f}\n" for f in ficheros), encoding="utf8") + run("git", "checkout", "-q", "FETCH_HEAD") + + +def traer_tarball(url: str, ficheros: list[str], dest: Path) -> None: + """Sin atajo posible: se baja el tarball y se extraen sólo los miembros que hacen falta. + + El primer componente del camino se descarta (equivale al `-p1` con el que se aplican después), + igual que hace hammer al extraer. + """ + quiero = set(ficheros) + with tempfile.NamedTemporaryFile(suffix=".tar", delete=True) as tmp: + with urllib.request.urlopen(url) as r: + shutil.copyfileobj(r, tmp) + tmp.flush() + with tarfile.open(tmp.name, "r:*") as tf: + for m in tf: + if not m.isfile(): + continue + rel = m.name.split("/", 1)[1] if "/" in m.name else m.name + if rel not in quiero: + continue + destino = dest / rel + destino.parent.mkdir(parents=True, exist_ok=True) + src = tf.extractfile(m) + if src: + destino.write_bytes(src.read()) + + +def probar(receta: Path, keep: bool, git_only: bool) -> tuple[str, list[str]]: + """Devuelve (estado, líneas del informe) para una receta. Estado ∈ ok|fuzz|falla|saltada.""" + datos = tomllib.loads(receta.read_text(encoding="utf8")) + src = datos.get("source", {}) + parches = src.get("patches") or [] + nombre = datos.get("name", receta.stem) + if not parches: + return "saltada", [f"{nombre}: sin parches"] + + textos = {} + ficheros: set[str] = set() + for p in parches: + ruta = receta.parent / p + if not ruta.exists(): + return "falla", [f"{nombre}: ✗ el parche declarado no existe en el disco: {p}"] + t = ruta.read_text(encoding="utf8", errors="replace") + textos[p] = t + ficheros.update(ficheros_del_parche(t)) + + es_git = "repo" in src + if not es_git and git_only: + return "saltada", [f"{nombre}: tarball, salteada por --git-only"] + + tmp = Path(tempfile.mkdtemp(prefix=f"vigia-parches-{nombre}-")) + try: + if es_git: + commit = src.get("commit") + if not commit or len(commit) < 40: + # Un tag flotante no es un pin (ADR 0006); tampoco se puede verificar contra él. + return "falla", [f"{nombre}: ✗ `commit` no es un sha completo: {commit!r}"] + traer_git(src["repo"], commit, sorted(ficheros), tmp) + else: + url = src.get("tarball") or src.get("url") + if not url: + return "saltada", [f"{nombre}: [source] sin `repo` ni `tarball`"] + traer_tarball(url, sorted(ficheros), tmp) + + lineas = [f"{nombre} ({datos.get('version','?')}) — {len(parches)} parches, " + f"{len(ficheros)} ficheros tocados"] + peor = "ok" + # Acumulativos y en orden, igual que fetch.rs::apply_patches. + for p in parches: + r = subprocess.run(["patch", "-p1", "--batch", "--verbose"], + cwd=tmp, input=textos[p], text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + hunks = RE_HUNK.findall(r.stdout) + fuzz = [(h[0], RE_FUZZ.search(h[3]).group(1)) for h in hunks if RE_FUZZ.search(h[3])] + offs = [h[0] for h in hunks if RE_OFFSET.search(h[3])] + base = os.path.basename(p) + if r.returncode != 0: + peor = "falla" + lineas.append(f" FALLA {base}") + for l in r.stdout.splitlines(): + if "FAILED" in l or "can't find file" in l or "Reversed" in l: + lineas.append(f" {l.strip()}") + elif fuzz: + if peor != "falla": + peor = "fuzz" + det = ", ".join(f"hunk #{h} fuzz {f}" for h, f in fuzz) + lineas.append(f" FUZZ {base} ({det}) — entra ADIVINANDO, mirá el diff") + else: + extra = f" ({len(offs)} hunks desplazados)" if offs else "" + lineas.append(f" ok {base}{extra}") + if keep: + lineas.append(f" árbol parcheado en {tmp}") + return peor, lineas + finally: + if not keep: + shutil.rmtree(tmp, ignore_errors=True) + + +def main() -> int: + ap = argparse.ArgumentParser(description="¿los parches de una receta siguen agarrando?") + ap.add_argument("recipes", nargs="*", type=Path) + ap.add_argument("--all", action="store_true", help="todas las recetas que declaren parches") + ap.add_argument("--git-only", action="store_true", help="saltear fuentes tarball (más caras)") + ap.add_argument("--keep", action="store_true", help="no borrar el árbol parcheado") + ap.add_argument("--strict", action="store_true", help="salir 1 también si algo entra con fuzz") + a = ap.parse_args() + + objetivos = list(a.recipes) + if a.all: + for f in sorted((ROOT / "recipes").glob("*.toml")): + if re.search(r"^\s*patches\s*=", f.read_text(encoding="utf8", errors="replace"), re.M): + objetivos.append(f) + if not objetivos: + ap.error("dame una receta o --all") + + peor = "ok" + orden = {"saltada": 0, "ok": 1, "fuzz": 2, "falla": 3} + for r in objetivos: + try: + estado, lineas = probar(r, a.keep, a.git_only) + except subprocess.CalledProcessError as e: + estado, lineas = "falla", [f"{r}: ✗ no pude traer la fuente ({e})"] + except Exception as e: # una receta rota no debe tumbar el barrido entero + estado, lineas = "falla", [f"{r}: ✗ {type(e).__name__}: {e}"] + print("\n".join(lineas)) + print() + if orden[estado] > orden[peor]: + peor = estado + + if peor == "falla": + return 1 + if peor == "fuzz" and a.strict: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())