`hammer build` aplica los parches DESPUÉS de materializar las dependencias, así que en una receta hoja de la plataforma Gecko el `patch` que no agarra se descubre detrás de horas de compilar OTRA COSA: waterfox estrena su primer build reconstruyendo nodejs entero (4414 objetivos de V8, a -j2 porque la propia receta capa por RAM). La receta de waterfox dice, con todas las letras, que si patch falla «falla TEMPRANO, antes de compilar nada». Con el orden real de hammer eso no era cierto. Este vigía es lo que lo vuelve cierto: saca de los propios parches los ficheros que tocan (21 en waterfox), los trae por sparse-checkout blob:none, y aplica los once acumulativos y en orden como hace fetch.rs. Un minuto en vez de cuatro horas. LO QUE MIDE NO ES SÍ/NO, SON TRES ESTADOS. `patch` también responde «sí, adivinando»: cuando el contexto no casa aplica igual con FUZZ, y con el `--silent` de fetch.rs eso sale por exit 0 sin que nadie se entere. Un parche con fuzz puede haber editado el sitio correcto o cualquier otro, y el exit code no distingue. Por eso `ok` / `FUZZ` / `FALLA`, y el del medio existe para no perderse. El desplazamiento no se marca: sólo dice que el fichero creció por arriba. VEREDICTO SOBRE LA APUESTA DE WATERFOX (parches de 154 sobre base 153.1.0): SE SOSTIENE. Los once entran; los dos que entran con fuzz —time64 y fix-rust-target— se verificaron a mano y los dos editan el sitio correcto. El contexto que no casa es cosmético: comillas simples vs dobles de un reformateo con black, y un brazo `cfg` de más. Y UN HALLAZGO DE PASO: `firefox-patches/time64.patch` tiene la cabecera MENTIROSA. Su diffstat anuncia tres ficheros y el cuerpo entrega dos — falta el hunk de `wgpu-hal/src/vulkan/adapter.rs`. Como firefox 154 sella igual, nadie lo había notado. Por eso los ficheros se leen del cuerpo y nunca del diffstat. El vigía nació mintiendo en la dirección cara y por eso se probó contra recetas selladas antes de commitear: suponía el prefijo `a/`+`b/` de git y marcaba FALLA sobre gawk, que sella perfecto — su parche es un `diff -upr` a secas con caminos `gawk-5.1.0.orig/`. Se descarta el primer componente se llame como se llame, que es lo que significa el -p1 con el que se aplican. Flags en inglés (--all, --git-only, --keep, --strict) por la regla 4 del repo.
250 lines
13 KiB
Python
Executable File
250 lines
13 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 ════════════════════════════════════════════════════════
|
|
# `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())
|