250 líneas de comentario en 151 scripts. Control verificado: el diff no toca NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa. El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces: las tres eran el MOTD que el script escribe DENTRO de la imagen construida — texto del producto, no comentario del script. Se cambiaron aparte y a propósito, que es rebranding, no limpieza. Y el hallazgo caro: casaba contra , que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate. La etapa 4 lo movió a y el script quedó casando NADA. No fallaba: imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja. Además 14 rutas de módulo en docs, que el barrido anterior no tocó porque no es frontera de palabra.
301 lines
15 KiB
Python
Executable File
301 lines
15 KiB
Python
Executable File
#!/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 ════════════════════════════════════════════════════════
|
|
# `takana 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 `takana 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.
|
|
# FUZZ✓ entra adivinando, PERO alguien ya abrió el diff y anotó dónde cayó, en
|
|
# `docs/state/fuzz-verificado.tsv`. La entrada está indexada por una huella del parche
|
|
# MÁS el pin de la fuente: si cualquiera de los dos cambia, deja de casar y vuelve a
|
|
# salir como FUZZ. Un fuzz mirado no debería preguntarse en cada corrida —eso vuelve
|
|
# ruido al vigía y así es como se pierde el FALLA del día que aparezca— pero tampoco
|
|
# debe poder silenciarse para siempre.
|
|
# 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 hashlib
|
|
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+)")
|
|
|
|
# ── EL LIBRO DE FUZZ YA MIRADOS ────────────────────────────────────────────────────────────────
|
|
# Un FUZZ no se puede resolver automáticamente: hay que abrir el diff y ver si el cambio cayó donde
|
|
# debía. Pero una vez mirado, el veredicto NO caduca solo — depende de dos cosas y las dos están
|
|
# pineadas: el TEXTO del parche y la FUENTE. Repreguntarlo en cada corrida convierte al vigía en
|
|
# ruido y se acaba ignorando (que es como se pierde el FALLA del día que aparezca).
|
|
#
|
|
# Por eso el libro se indexa por una HUELLA de las dos cosas. Si alguien toca el parche, o sube la
|
|
# versión de la receta, la huella cambia, la entrada deja de casar y el vigía vuelve a preguntar.
|
|
# Es lo contrario de una lista de excepciones: no hay forma de silenciar algo y que se quede
|
|
# silenciado cuando cambió.
|
|
LIBRO = Path(__file__).resolve().parent.parent / "docs/state/fuzz-verificado.tsv"
|
|
|
|
|
|
def huella(texto_parche: str, src: dict) -> str:
|
|
"""Identidad de un (parche, fuente) pineados. Cambiá cualquiera de los dos y cambia."""
|
|
pin = src.get("sha256") or src.get("commit") or ""
|
|
h = hashlib.sha256()
|
|
h.update(texto_parche.encode())
|
|
h.update(b"\n")
|
|
h.update(pin.encode())
|
|
return h.hexdigest()[:16]
|
|
|
|
|
|
def cargar_libro() -> dict:
|
|
if not LIBRO.exists():
|
|
return {}
|
|
out = {}
|
|
for linea in LIBRO.read_text(encoding="utf8").splitlines():
|
|
if linea.startswith("#") or not linea.strip():
|
|
continue
|
|
campos = linea.split("\t")
|
|
if len(campos) >= 4:
|
|
out[(campos[0], campos[1], campos[2])] = campos[3]
|
|
return out
|
|
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, libro: dict) -> 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:
|
|
det = ", ".join(f"hunk #{h} fuzz {f}" for h, f in fuzz)
|
|
hu = huella(textos[p], src)
|
|
visto = libro.get((nombre, base, hu))
|
|
if visto:
|
|
lineas.append(f" FUZZ✓ {base} ({det}) — ya mirado: {visto}")
|
|
else:
|
|
if peor != "falla":
|
|
peor = "fuzz"
|
|
lineas.append(f" FUZZ {base} ({det}) — entra ADIVINANDO, mirá el diff")
|
|
# La línea lista para pegar en el libro DESPUÉS de mirarlo. Se da acá porque
|
|
# calcular la huella a mano es justo la fricción que hace que nadie lo anote.
|
|
lineas.append(f" libro: {nombre}\t{base}\t{hu}\t<tu veredicto>")
|
|
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"
|
|
libro = cargar_libro()
|
|
orden = {"saltada": 0, "ok": 1, "fuzz": 2, "falla": 3}
|
|
for r in objetivos:
|
|
try:
|
|
estado, lineas = probar(r, a.keep, a.git_only, libro)
|
|
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())
|