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…
vigía de parches: probar que agarran ANTES de las cuatro horas de build
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())