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…
⎇ 93c035f15f8f8aab331a99650e173b325ebf91b0
scripts/vigia-parches.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
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())