Files
Sergio 476168bb07 takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
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.
2026-09-09 19:28:48 +00:00

479 lines
26 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# seed-graph.py — SIEMBRA las aristas de los nodos `wanted` del grafo desde la metadata de nixpkgs,
# SIN construir nada. (P3 del plan docs/plan-catalogo-objetivo.md.)
#
# POR QUÉ. `build-state.py` sabe qué falta (nodos `wanted`) pero no de qué cuelga: las deps de un
# paquete sin receta no se conocen hasta escribirla. Eso deja la clausura de cada perfil como cota
# inferior y el orden de ataque a ciegas. nixpkgs ya tiene ese grafo; se extrae con `nix eval` (sólo
# METADATA — leerla no viola el ADR 0004, que prohíbe *construir* con nix).
#
# LA SEMILLA ES UNA HIPÓTESIS, NO LA VERDAD. Son las deps de OTRA distro con OTRAS decisiones de
# build (qué opciones, qué backends). Sirve para hacer la frontera contable y ordenable, no para
# creerle. Por eso:
# · sale a `docs/state/seed-edges.json`, un fichero APARTE, con procedencia por arista;
# · NUNCA se escribe en `recipes/` ni en `targets.toml`;
# · sólo se consulta para nodos SIN receta — en cuanto hay receta, manda la receta (ya corregida
# por el veredicto de harkaq). La semilla se auto-borra a medida que el frente se drena.
#
# Cada arista sembrada se CLASIFICA, que es donde está el valor:
# conocida — tras normalizar, hay receta con ese nombre. Arista utilizable ya.
# frontera — no hay receta y no es un nix-ismo ⇒ candidato a nodo `wanted` nuevo.
# nix-ismo — andamiaje de nixpkgs que en takana no existe (hooks, wrappers, doc tooling).
# lab — toolchain que provee el rootfs del sandbox, no una receta (cargo/rustc).
#
# CALIBRADO, no supuesto (medición completa en el plan). Muestra de 60 recetas / 686 deps:
# aristas directas → 59% de precisión; el cierre transitivo sube el recall a 84% pero se lleva la
# precisión a 6% (4629 nodos fantasma del bootstrap de nixpkgs) ⇒ **se siembran DIRECTAS y la
# transitividad la hace el grafo**, que es lo que `build-state.py` ya sabe hacer con las aristas.
#
# Uso: scripts/seed-graph.py --calibrar [N] [--transitivo] # mide el ruido contra recetas escritas
# scripts/seed-graph.py --frontera <perfil> # qué pide nixpkgs y takana no tiene → seed-frontera.json
# scripts/seed-graph.py --perfil <perfil> # siembra los nodos `wanted` → seed-edges.json
# scripts/seed-graph.py <nombre...> # siembra nombres sueltos
# Env: NIXPKGS (def nixpkgs), NIX_ROOT (def ~/.nixstore; vacío = store del sistema)
import shutil, json, os, re, subprocess, sys, glob, tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
os.chdir(ROOT)
sys.path.insert(0, str(ROOT / "scripts"))
import targets as targets_mod
TRANSITIVO = "--transitivo" in sys.argv # comparar CLAUSURA sembrada vs deps aplanadas de hammer
NIXPKGS = os.environ.get("NIXPKGS", "nixpkgs")
# El store de nix va al `work/` del repo, NO a `~/.nixstore` (2026-09-08). En gioser `$HOME`
# vive en `/` (74 G, 21 libres) y el volumen tiene 126: llenar `/` no rompe una tanda, rompe
# la MÁQUINA. Y `work/` ya es el sitio de lo pesado-y-regenerable, así que la poda lo alcanza.
# Medido: el store de un eval son ~8 MB; lo gordo es la caché git de nixpkgs (~69 MB), que
# nix pone en `$XDG_CACHE_HOME` y por eso NO la gobierna esta variable — en gioser está
# mudada al volumen con un enlace (`~/.cache/nix -> /mnt/vvv/nix-cache`).
NIX_ROOT = os.environ.get("NIX_ROOT", str(ROOT / "work/nixstore"))
OUT = ROOT / "docs/state/seed-edges.json"
FRONTERA = ROOT / "docs/state/seed-frontera.json"
LOTE = 120 # nombres por proceso de `nix eval` (uno solo evalúa 6 en 0.7s; el costo es el arranque)
# --- normalización nix → takana -------------------------------------------------------------------
# Mapeo por evidencia, no por intuición: cada entrada salió de una discrepancia vista en --calibrar.
MAPA = {
"ninja": "samurai", # hammer usa samurai como backend de meson
"pkg-config": "pkgconf",
"pkg-config-wrapper": "pkgconf",
"libpng-apng": "libpng", # nixpkgs sirve libpng con el parche APNG
"gettext": "gettext-tiny", # hammer usa la implementación tiny
"gnum4": "m4",
"XML-Parser": "perl-xml-parser",
"bash-interactive": "bash",
"util-linux-minimal": "util-linux",
"cacert": "ca-certificates",
}
# Andamiaje de nixpkgs sin contraparte en takana: hooks del stdenv, wrappers, tooling de docs.
NIXISMOS_EXACTOS = {
"", "which", "stdenv", "gtk-doc", "docbook-xml", "docbook-xsl", "docbook-xsl-nons",
"docbook-xsl-ns", "docbook2X", "asciidoc", "asciidoctor", "install-shell-files",
"fix-darwin-dylib-names", "update-autotools-gnu-config-scripts-hook",
"gobject-introspection-wrapped", "nuke-refs", "auditable-cargo", "glibc-iconv",
"glibc-locales", "cmake-minimal",
}
NIXISMOS_SUFIJOS = ("-hook", "Hook", "-wrapper", "-setup-hook", ".sh")
# Lazo cerrado: los candidatos que un humano marcó `nix-ismo` en el triaje (P5) vuelven acá como
# descarte, así cada corrida del sembrador sale más limpia que la anterior. Si el fichero no está,
# no pasa nada — el sembrador funciona igual, sólo con más ruido.
_TRIAJE = ROOT / "docs/state/nixismos-triaje.txt"
if _TRIAJE.exists():
NIXISMOS_EXACTOS |= {l.strip() for l in _TRIAJE.read_text().splitlines()
if l.strip() and not l.startswith("#")}
# Y los `provisto`: nombres que nixpkgs separa en un paquete propio y acá los da otra receta
# (`mesa-libgbm`→mesa). Entran al MAPA, así se resuelven como arista CONOCIDA en vez de hueco.
_ALIAS = ROOT / "docs/state/alias-triaje.txt"
if _ALIAS.exists():
for _l in _ALIAS.read_text().splitlines():
if _l.strip() and not _l.startswith("#"):
_p = _l.split()
if len(_p) == 2:
MAPA.setdefault(_p[0], _p[1])
NIXISMOS_INFIJOS = ("-hook",) # `cargo-build-hook.sh` no termina en `-hook`, lo contiene
# Toolchains que provee el LAB (el rootfs del sandbox), no una receta: ninguna receta Rust declara
# `rust` ni `cargo` en `[deps].build` — están vacías y el toolchain viene de abajo. Que nixpkgs los
# liste no es un hueco de takana; es que las dos distros ponen la frontera en lugares distintos.
PROVISTO_POR_EL_LAB = {"cargo", "rustc", "rust"}
# Deps que takana declara SIEMPRE y nix nunca lista porque las provee el stdenv implícito. No son
# un fallo del sembrador: son convención del sandbox de takana (cada dep es una capa --overlay-src).
CONVENCION_HAMMER = {"make", "binutils", "linux-headers"}
# Sufijos de VARIANTE de takana: `zlib` y `zlib-shared` son el mismo paquete con otra decisión de
# enlazado. nixpkgs no puede saber cuál eligió takana ⇒ no es un fallo del sembrador, es una
# decisión nuestra. Se cuenta aparte para no inflar ni el acierto ni el error.
VARIANTES = ("-shared", "-static")
def normalizar(n, por_minusculas=None):
"""nix-name → (hammer-name, es_nixismo). El mapeo va ANTES del descarte (`pkg-config-wrapper`
termina en `-wrapper` pero es pkgconf, dep real). Con `por_minusculas` ({nombre.lower(): nombre
real}) resuelve además las diferencias de caja: nixpkgs dice `libx11`, hammer `libX11`."""
if n in MAPA:
return MAPA[n], False
# nixpkgs a veces deja la versión pegada al pname (`auditable-cargo-1.96.1`, `glibc-iconv-2.42`):
# se despega para poder reconocer la base. Sólo si el sufijo es puramente numérico.
base = re.sub(r"-\d[\d.]*$", "", n)
if base != n and (base in NIXISMOS_EXACTOS or base in MAPA):
return MAPA.get(base, base), base not in MAPA
if (n in NIXISMOS_EXACTOS or any(n.endswith(s) for s in NIXISMOS_SUFIJOS)
or any(s in n for s in NIXISMOS_INFIJOS)):
return n, True
# `-wrapped` / `-unwrapped` son variantes de empaquetado de nix, no paquetes distintos.
for suf in ("-unwrapped", "-wrapped"):
if n.endswith(suf):
n = n[: -len(suf)]
break
if por_minusculas:
n = por_minusculas.get(n.lower(), n)
return n, False
def raiz_variante(n):
"""`zlib-shared` → `zlib`. Para comparar sin castigar una decisión de enlazado nuestra."""
for suf in VARIANTES:
if n.endswith(suf):
return n[: -len(suf)]
return n
# --- lectura del corpus ---------------------------------------------------------------------------
def recetas():
"""{nombre: [deps de build]} sobre `recipes/` + las colas incoming-*."""
out = {}
for pat in ["recipes/*.toml", "recipes/incoming-*/*.toml"]:
for f in sorted(glob.glob(pat)):
n = os.path.basename(f)[:-5]
try:
d = tomllib.load(open(f, "rb"))
except Exception:
continue
out.setdefault(n, d.get("deps", {}).get("build", []))
return out
# --- extracción desde nixpkgs ---------------------------------------------------------------------
# Los paquetes NO viven todos en el top-level de nixpkgs: el escritorio KDE —justo el objetivo de
# este frente— cuelga de `kdePackages.` (Plasma 6) o `libsForQt5.`. Sin este fallback, 22 de 40
# nombres de la primera calibración salían "sin attr en nixpkgs" y la frontera KDE era insembrable.
# `ns` viaja en la respuesta: de dónde salió cada dep es parte de su procedencia.
EXPR = """
pkgs: let
names = [ %s ];
espacios = [ { ns = ""; set = pkgs; } ]
++ (if pkgs ? kdePackages then [ { ns = "kdePackages"; set = pkgs.kdePackages; } ] else [])
++ (if pkgs ? libsForQt5 then [ { ns = "libsForQt5"; set = pkgs.libsForQt5; } ] else []);
buscar = n: let hits = builtins.filter (e: e.set ? ${n}) espacios;
in if hits == [] then null else builtins.head hits;
uno = n: let e = buscar n; in if e == null then null else
let r = builtins.tryEval (let p = e.set.${n}; v = {
ns = e.ns;
bi = map (x: x.pname or x.name or "") (p.buildInputs or []);
nbi = map (x: x.pname or x.name or "") (p.nativeBuildInputs or []);
pbi = map (x: x.pname or x.name or "") (p.propagatedBuildInputs or []);
}; in builtins.deepSeq v v);
in if r.success then r.value else null;
in builtins.listToAttrs (map (n: { name = n; value = uno n; }) names)
"""
def nix_deps(nombres):
"""{nombre: {bi,nbi,pbi}} o None si nixpkgs no lo tiene / su eval falla. En LOTES: el costo es
el arranque del evaluador, no el paquete (6 en 0.7s con el eval cache caliente)."""
res = {}
for i in range(0, len(nombres), LOTE):
chunk = nombres[i : i + LOTE]
expr = EXPR % " ".join(json.dumps(n) for n in chunk)
cmd = ["nix", "eval", "--extra-experimental-features", "nix-command flakes"]
if NIX_ROOT:
cmd += ["--store", f"local?root={NIX_ROOT}"]
cmd += ["--json", f"{NIXPKGS}#legacyPackages.x86_64-linux", "--apply", expr]
print(f" nix eval lote {i//LOTE+1} ({len(chunk)} nombres)…", file=sys.stderr)
p = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
if p.returncode != 0:
print(f" ⚠ el lote falló: {p.stderr.strip()[:300]}", file=sys.stderr)
res.update({n: None for n in chunk})
continue
res.update(json.loads(p.stdout))
return res
def sembrar(nombres, recs):
"""nombre → [{dep, clase, origen_nix}]. Une bi+nbi+pbi: las deps REALES de varios paquetes viven
en `propagatedBuildInputs` (freetype: zlib/bzip2/brotli/libpng están ahí, no en buildInputs)."""
por_minusculas = {r.lower(): r for r in recs}
crudo = nix_deps(nombres)
out = {}
for n in nombres:
d = crudo.get(n)
if d is None:
out[n] = None # nixpkgs no lo tiene: la frontera queda sin sembrar (candidato a Alpine)
continue
vistos, aristas = set(), []
for campo in ("nbi", "bi", "pbi"):
for crudo_n in d.get(campo) or []:
dep, nixismo = normalizar(crudo_n, por_minusculas)
if dep in vistos:
continue
vistos.add(dep)
clase = ("nix-ismo" if nixismo else "lab" if dep in PROVISTO_POR_EL_LAB
else "conocida" if dep in recs else "frontera")
aristas.append(dict(dep=dep, clase=clase, origen_nix=crudo_n, campo=campo,
ns=d.get("ns") or "top-level"))
out[n] = aristas
return out
# --- modos ----------------------------------------------------------------------------------------
def sembrar_cierre(raices, recs, prof_max=4, tope=4000):
"""Siembra en ONDAS hasta punto fijo: las deps de las deps. Necesario porque las dos distros
declaran cosas distintas — hammer APLANA la clausura en `[deps].build` (cada dep es una capa
--overlay-src del sandbox: si no está declarada, no está en el árbol), mientras nix declara sólo
las DIRECTAS y deja que la propagación haga el resto. Comparar unas con otras mide un artefacto
de la comparación, no la calidad de la semilla.
Devuelve {nombre: [aristas]} de TODO lo visitado; la clausura se calcula después sobre ese grafo
(que es también lo que hace `build-state.py` con las aristas reales)."""
grafo, pendientes, prof = {}, list(dict.fromkeys(raices)), 0
while pendientes and prof < prof_max and len(grafo) < tope:
prof += 1
print(f" onda {prof}: {len(pendientes)} nombres nuevos", file=sys.stderr)
nuevo = sembrar(pendientes, recs)
grafo.update(nuevo)
siguientes = {a["dep"] for ar in nuevo.values() if ar for a in ar
if a["clase"] not in ("nix-ismo", "lab") and a["dep"] not in grafo}
pendientes = sorted(siguientes)[: max(0, tope - len(grafo))]
return grafo
def clausura_sembrada(raiz, grafo):
"""Alcanzables desde `raiz` por las aristas sembradas (sin la raíz)."""
vistos, pila = set(), [a["dep"] for a in (grafo.get(raiz) or [])
if a["clase"] not in ("nix-ismo", "lab")]
while pila:
n = pila.pop()
if n in vistos:
continue
vistos.add(n)
for a in grafo.get(n) or []:
if a["clase"] not in ("nix-ismo", "lab") and a["dep"] not in vistos:
pila.append(a["dep"])
return vistos
def calibrar(recs, n_muestra):
"""Mide la semilla contra la VERDAD que ya tenemos: las recetas escritas a mano. Es la única
forma honesta de saber si sembrar sirve, antes de sembrar nada."""
# Candidatas: recetas C/GUI con deps declaradas (las Rust/Go declaran sólo el toolchain).
cand = [n for n, deps in sorted(recs.items())
if deps and "go" not in deps and "rust" not in deps and "cargo" not in deps]
paso = max(1, len(cand) // n_muestra)
muestra = cand[::paso][:n_muestra]
print(f"== calibrando sobre {len(muestra)} recetas con deps declaradas (de {len(cand)})")
if TRANSITIVO:
grafo = sembrar_cierre(muestra, recs)
sem = {n: ([dict(dep=d, clase=("conocida" if d in recs else "frontera"), origen_nix=d,
campo="cierre", ns="") for d in sorted(clausura_sembrada(n, grafo))]
if grafo.get(n) is not None else None) for n in muestra}
else:
sem = sembrar(muestra, recs)
tot_v = tot_ok = tot_falta_real = tot_falta_conv = tot_variante = 0
sobra_con_receta, sobra_frontera, nixismos = {}, {}, {}
sin_nix, detalle = [], []
for n in muestra:
aristas = sem.get(n)
if aristas is None:
sin_nix.append(n)
continue
for a in aristas:
if a["clase"] == "nix-ismo":
nixismos[a["origen_nix"]] = nixismos.get(a["origen_nix"], 0) + 1
sembrado = {a["dep"] for a in aristas if a["clase"] not in ("nix-ismo", "lab")}
verdad = set(recs[n])
ok = verdad & sembrado
falta = verdad - sembrado
# `zlib-shared` declarada y `zlib` sembrada: acertó el paquete, no la variante. Aparte.
raices_sembradas = {raiz_variante(s) for s in sembrado}
variante = {f for f in falta if raiz_variante(f) in raices_sembradas}
falta -= variante
falta_conv = falta & CONVENCION_HAMMER
falta_real = falta - CONVENCION_HAMMER
sobra = {s for s in sembrado - verdad if raiz_variante(s) not in
{raiz_variante(v) for v in verdad}}
for s in sobra:
destino = sobra_con_receta if s in recs else sobra_frontera
destino[s] = destino.get(s, 0) + 1
tot_v += len(verdad); tot_ok += len(ok); tot_variante += len(variante)
tot_falta_real += len(falta_real); tot_falta_conv += len(falta_conv)
detalle.append((n, len(verdad), len(ok), sorted(falta_real), sorted(sobra)))
print(f"\n== VEREDICTO (verdad = las {tot_v} deps declaradas a mano en la muestra)")
if tot_v:
print(f" aciertos {tot_ok:4} ({100*tot_ok//tot_v}% de la verdad)")
print(f" faltan (reales) {tot_falta_real:4} ({100*tot_falta_real//tot_v}%) ← la semilla NO las ve")
print(f" faltan (convención hammer: {' '.join(sorted(CONVENCION_HAMMER))}) {tot_falta_conv:4}"
f" ← nix las da por implícitas en el stdenv; NO es ruido, es constante")
print(f" variante distinta {tot_variante:4} ← acertó el paquete, erró -shared/-static"
f" (decisión NUESTRA, nix no puede saberla)")
n_sobra = sum(sobra_con_receta.values()) + sum(sobra_frontera.values())
print(f" sobrantes {n_sobra:4} = {sum(sobra_con_receta.values())} con receta en hammer"
f" (deps que hammer NO necesitó) + {sum(sobra_frontera.values())} sin receta")
print(f" nix-ismos filtrados {sum(nixismos.values()):3} (andamiaje descartado antes de contar)")
if sin_nix:
print(f" sin attr en nixpkgs {len(sin_nix):3}: {' '.join(sin_nix[:12])}")
print("\n== los 15 sobrantes SIN receta más frecuentes (candidatos a MAPA o a nix-ismo)")
for k, v in sorted(sobra_frontera.items(), key=lambda kv: -kv[1])[:15]:
print(f" {v:3}× {k}")
print("\n== peores casos (más deps reales no vistas)")
for n, nv, nok, falta, sobra in sorted(detalle, key=lambda d: -len(d[3]))[:8]:
print(f" {n:22} verdad {nv:2}, acierta {nok:2}, no ve: {' '.join(falta) or '—'}")
return 0
def frontera(recs, perfil):
"""Qué dice nixpkgs que hace falta y hammer NO TIENE RECETA para construir, mirando las recetas
que YA existen en la clausura de un perfil. Es el modo útil mientras no haya nodos `wanted`:
descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare.
Cada candidato es una HIPÓTESIS a clasificar por un humano: puede ser (a) una dep opcional que
hammer no habilitó a propósito, (b) un nix-ismo que falta en la lista de descarte, o (c) un
hueco real. El sembrador no decide cuál: los ordena por cuántas recetas los piden."""
# El grafo se elige por la COLA que el perfil declara, no por cuál tiene más nodos: la cola
# `incoming-kde` SOMBREA recetas canónicas, así que medir un perfil del corpus contra el grafo
# KDE mide una imagen que nadie construye.
try:
cola = targets_mod.load().get(perfil, {}).get("cola", "corpus")
except Exception:
cola = "corpus"
en_perfil, grafo_usado = [], None
# ⚠ EL FICHERO SE DERIVA DE LA COLA, no se escribe a mano (2026-09-09). Esta lista estaba fija y
# sólo tenía corpus/kde/gnome, así que `escritorio-sway` y `escritorio-cosmic` eran INVISIBLES
# para la frontera: nadie podía preguntar «¿qué le falta a sway?» y obtener respuesta. Y el
# error mentía sobre la causa — «¿regeneraste con build-state.py?» — con los cinco grafos
# frescos de hacía minutos.
# Es la QUINTA vez que cae el mismo cable en este repo: GNOME (2026-07-27), COSMIC
# (2026-08-03) y wlr/sway (2026-08-26) en `cosecha-cron.sh`, keystones y duplicados
# (2026-09-08) en el mismo sitio, y ahora acá. Las cuatro anteriores se arreglaron AGREGANDO
# una línea, que es arreglar el caso y dejar la trampa. Derivarlo de la cola la quita: una cola
# nueva trae su grafo sola. `corpus` → build-state.json; `incoming-<x>` → build-state-<x>.json.
cand_files = ["docs/state/build-state.json"] if cola == "corpus" else [
f"docs/state/build-state-{cola.removeprefix('incoming-')}.json",
"docs/state/build-state.json"]
for f in cand_files:
p = ROOT / f
if not p.exists():
continue
d = json.loads(p.read_text())
if cola not in (d.get("by_queue") or {}):
continue
hit = sorted(n for n, r in d["nodes"].items() if perfil in (r.get("perfiles") or []))
if hit:
en_perfil, grafo_usado = hit, f
break
if not en_perfil:
print(f"perfil `{perfil}` sin nodos en ningún grafo de estado "
f"(¿regeneraste con scripts/build-state.py [--kde]?)", file=sys.stderr)
return 1
print(f" (grafo: {grafo_usado})")
print(f"== sembrando las {len(en_perfil)} recetas de la clausura de `{perfil}`")
sem = sembrar(en_perfil, recs)
cand, sin_nix = {}, []
for n, aristas in sem.items():
if aristas is None:
sin_nix.append(n); continue
for a in aristas:
if a["clase"] == "frontera":
cand.setdefault(a["dep"], []).append(n)
print(f" sin attr en nixpkgs: {len(sin_nix)}"
+ (f" ({' '.join(sin_nix[:10])}…)" if sin_nix else ""))
orden = sorted(cand.items(), key=lambda kv: (-len(kv[1]), kv[0]))
print(f"\n== {len(cand)} candidatos a HUECO (nixpkgs los pide, hammer no tiene receta)")
for dep, quienes in orden[:40]:
print(f" {len(quienes):3}× {dep:28}{' '.join(sorted(quienes)[:5])}"
+ (" …" if len(quienes) > 5 else ""))
# Artefacto durable y versionado: el `git diff` de este fichero muestra qué huecos APARECEN
# entre dos corridas (una receta nueva puede destapar deps que nadie había pedido antes).
prev = json.loads(FRONTERA.read_text()) if FRONTERA.exists() else {"perfiles": {}}
prev.setdefault("perfiles", {})[perfil] = dict(
recetas_sembradas=len(en_perfil), sin_attr_en_nixpkgs=sin_nix,
candidatos={dep: sorted(q) for dep, q in orden})
prev["schema"] = "hammer-seed-frontera/1"
prev["advertencia"] = ("HIPÓTESIS desde metadata de nixpkgs. Cada candidato es (a) dep opcional "
"que hammer no habilitó, (b) nix-ismo por filtrar, o (c) hueco real. "
"Clasificar a mano; NO promover a receta automáticamente.")
FRONTERA.write_text(json.dumps(prev, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
print(f"\n{FRONTERA.relative_to(ROOT)} (perfil `{perfil}`)")
return 0
def sembrar_a_fichero(nombres, recs):
sem = sembrar(nombres, recs)
doc = dict(
schema="hammer-seed-edges/1",
fuente="nixpkgs (metadata; NO se construye con nix — ADR 0004)",
advertencia="HIPÓTESIS, no verdad. Sólo válido para nodos SIN receta; con receta, manda la receta.",
nodos={n: (a if a is not None else "sin-attr-en-nixpkgs") for n, a in sorted(sem.items())},
)
OUT.write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
n_ok = sum(1 for a in sem.values() if a)
conocidas = sum(1 for a in sem.values() if a for x in a if x["clase"] == "conocida")
frontera = sorted({x["dep"] for a in sem.values() if a for x in a if x["clase"] == "frontera"})
print(f"== sembrados {n_ok}/{len(nombres)} nodos → {OUT.relative_to(ROOT)}")
print(f" aristas `conocida` (hay receta): {conocidas}")
print(f" `frontera` (candidatos a nodo wanted nuevo): {len(frontera)}")
if frontera:
print(" " + " ".join(frontera[:30]) + (" …" if len(frontera) > 30 else ""))
return 0
def main():
args = sys.argv[1:]
if not args:
print(__doc__ or "uso: seed-graph.py --calibrar [N] | --frontera <p> | --perfil <p> | <nombre...>",
file=sys.stderr)
return 1
# ⚠ PRECONDICIÓN EXPLÍCITA, y no es cosmética (2026-09-08). TODOS los verbos de este script
# preguntan a `nix eval` qué declara nixpkgs — incluido `--calibrar`, que yo había eximido por
# suposición hasta que el control lo desmintió en el acto. Sin `nix` en el PATH, la llamada moría
# con veinte líneas de traceback terminadas en `FileNotFoundError: 'nix'`, que es la cara de un
# BUG DE CÓDIGO: manda a depurar el script cuando lo único que falta es una herramienta que en
# esta máquina nunca se instaló. El coste no es el susto, es el desvío. Un fallo de ENTORNO tiene
# que decir qué falta y qué hacer; va DESPUÉS del uso, para que `seed-graph.py` a secas siga
# explicándose.
if not shutil.which("nix"):
print("seed-graph: falta `nix` en el PATH.\n"
" Este script pregunta a nixpkgs (`nix eval`) qué declara upstream como dependencias;\n"
" sin él no hay frontera que calcular. No es un fallo de las recetas ni del grafo.\n"
" El hub `gioser` no lo trae: los `docs/state/seed-*.json` del repo se generaron en\n"
" otra máquina (2026-08-10). Instalar nix, o correr este verbo donde ya esté.",
file=sys.stderr)
return 2
recs = recetas()
if not args:
print(__doc__ or "uso: seed-graph.py --calibrar [N] | --perfil <p> | <nombre...>", file=sys.stderr)
return 1
if args[0] == "--calibrar":
n = next((int(a) for a in args[1:] if a.isdigit()), 40)
return calibrar(recs, n)
if args[0] == "--frontera":
if len(args) < 2:
print("uso: seed-graph.py --frontera <perfil>", file=sys.stderr); return 1
return frontera(recs, args[1])
if args[0] == "--perfil":
perfiles = targets_mod.load()
p = perfiles.get(args[1])
if not p:
print(f"perfil desconocido: {args[1]}", file=sys.stderr); return 1
pend = [r for r in p["raices"] if r not in recs]
if not pend:
print(f"el perfil `{args[1]}` no tiene nodos sin receta: nada que sembrar."); return 0
return sembrar_a_fichero(pend, recs)
return sembrar_a_fichero(args, recs)
if __name__ == "__main__":
sys.exit(main())