tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ main crates/takana-build/src/lib.rs Rust · 1544 líneas · 77.2 KB Historia Quién tocó qué crudo

paso 6: ADR 0012 decidido e implementado — la carrera del árbol de fuentes se cierra con un lock por receta 0a4b3abb4 · Sergio ·

1//! El laboratorio: compila una receta de forma hermética y la sella en el store; e hidrata
2//! artefactos al FHS. Ver `docs/02-build-lab.md` y `docs/03-hydration.md`.
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use takana_core::{ArtifactHash, Compiler, LinkMode, Phases, Recipe, Store};
8
9pub mod config;
10pub mod download;
11pub mod fetch;
12pub mod harkaq;
13pub mod hydrate;
14pub mod sandbox;
15pub mod swm_bridge;
16
17pub use config::BuildConfig;
18pub use hydrate::{hydrate as run_hydrate, DynamicSpec, HydrateReport, HydratedFile};
19pub use sandbox::{BuildFailure, Sandbox};
20pub use swm_bridge::build_source_patch;
21
22/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
23/// Ver `docs/02-build-lab.md` §2 y §5.
24///
25/// **Catálogo = `base_dir` de la receta.** Cada nombre en `deps.build` se resuelve al hermano
26/// `<base_dir>/<dep>.toml`; su hash entra en el de la receta (Merkle sobre el subgrafo de build).
27/// Así dos recetas con la misma fuente pero distinto grafo de deps hashean distinto, y cambiar
28/// una dep transitiva invalida la caché de todo lo que la usa.
29///
30/// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada
31/// de disco) no puede resolver su catálogo; un `<dep>.toml` ausente, o un ciclo de deps, también
32/// fallan con un mensaje que nombra la cadena.
33pub fn artifact_hash(
34    recipe: &Recipe,
35    _store: &Store,
36    lab: &takana_core::lab::LabFingerprint,
37) -> takana_core::Result<ArtifactHash> {
38    let mut stack: Vec<String> = Vec::new();
39    // Memo por path canónico de receta: el grafo de build es un DAG-diamante (p.ej. TODO KF6/Qt
40    // converge en qtbase/kcoreaddons, alcanzables por decenas de rutas). Sin memoizar, el walk
41    // recursivo es exponencial en NÚMERO DE CAMINOS y cuelga en paquetes grandes (plasma-workspace,
42    // ~90 deps). El hash de una receta es función pura de su subárbol ⇒ computar una vez por nodo.
43    // Clave = path resuelto (no `name`): dos variantes homónimas (incoming-kde/libxml2 vs canónica)
44    // no deben colisionar. Ver granja-promote-colisiones.
45    let mut memo: HashMap<PathBuf, ArtifactHash> = HashMap::new();
46    artifact_hash_rec(recipe, &mut stack, &mut memo, lab)
47}
48
49fn artifact_hash_rec(
50    recipe: &Recipe,
51    stack: &mut Vec<String>,
52    memo: &mut HashMap<PathBuf, ArtifactHash>,
53    lab: &takana_core::lab::LabFingerprint,
54) -> takana_core::Result<ArtifactHash> {
55    if stack.iter().any(|n| n == &recipe.name) {
56        stack.push(recipe.name.clone());
57        return Err(takana_core::Error::Recipe(format!(
58            "ciclo de dependencias de build: {}",
59            stack.join(" -> ")
60        )));
61    }
62
63    let dep_hashes = resolve_build_dep_hashes(recipe, stack, memo, lab)?;
64    let inputs = recipe.hash_inputs(&dep_hashes, lab)?;
65    let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
66    Ok(ArtifactHash::of_inputs(&refs))
67}
68
69/// Resuelve la ruta de una dep de build: primero como HERMANO (`base_dir/{dep}.toml`), y si no
70/// existe, en el catálogo PADRE (`base_dir/../{dep}.toml`). El fallback al padre permite que una
71/// receta en `recipes/incoming/` (cola de staging) dependa de una del corpus `recipes/` sin
72/// duplicarla ni symlinkearla — destraba la frontera de deps-del-corpus (openssl, zlib, …) en la
73/// granja, que construye desde `incoming/`. Si ninguna existe, devuelve la ruta hermana (para que
74/// el error apunte al lugar esperado).
75fn resolve_dep_path(base_dir: &Path, dep: &str) -> PathBuf {
76    let sibling = base_dir.join(format!("{dep}.toml"));
77    if sibling.exists() {
78        return sibling;
79    }
80    if let Some(parent) = base_dir.parent() {
81        let in_parent = parent.join(format!("{dep}.toml"));
82        if in_parent.exists() {
83            return in_parent;
84        }
85    }
86    sibling
87}
88
89/// Resuelve y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del
90/// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos.
91fn resolve_build_dep_hashes(
92    recipe: &Recipe,
93    stack: &mut Vec<String>,
94    memo: &mut HashMap<PathBuf, ArtifactHash>,
95    lab: &takana_core::lab::LabFingerprint,
96) -> takana_core::Result<Vec<ArtifactHash>> {
97    if recipe.deps.build.is_empty() {
98        return Ok(Vec::new());
99    }
100    if recipe.base_dir.as_os_str().is_empty() {
101        return Err(takana_core::Error::Recipe(format!(
102            "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
103             cárgala con Recipe::load_from_path para resolver el catálogo",
104            recipe.name, recipe.deps.build
105        )));
106    }
107
108    stack.push(recipe.name.clone());
109    let mut hashes = Vec::with_capacity(recipe.deps.build.len());
110    for dep in &recipe.deps.build {
111        let dep_path = resolve_dep_path(&recipe.base_dir, dep);
112        // Clave del memo: path canónico (colapsa `./a/../b` y symlinks a una identidad estable).
113        let key = dep_path.canonicalize().unwrap_or_else(|_| dep_path.clone());
114        if let Some(h) = memo.get(&key) {
115            hashes.push(h.clone());
116            continue;
117        }
118        let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
119            takana_core::Error::Recipe(format!(
120                "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
121                recipe.name,
122                dep_path.display()
123            ))
124        })?;
125        let h = artifact_hash_rec(&dep_recipe, stack, memo, lab)?;
126        memo.insert(key, h.clone());
127        hashes.push(h);
128    }
129    stack.pop();
130    Ok(hashes)
131}
132
133/// Construye (si falta) cada `deps.build` y devuelve sus directorios sellados en el store, para
134/// apilarlos como capas overlay del sandbox (variante b: bwrap necesita libcap.a + sys/capability.h,
135/// ausentes del toolchain Alpine). Recursivo vía `build`: una dep con sus propias deps se materializa
136/// transitivamente. Los ciclos ya los corta `artifact_hash` (con su `stack`) antes de llegar acá.
137fn materialize_build_deps(
138    recipe: &Recipe,
139    cfg: &BuildConfig,
140    store: &Store,
141) -> takana_core::Result<Vec<PathBuf>> {
142    if recipe.deps.build.is_empty() {
143        return Ok(Vec::new());
144    }
145    if recipe.base_dir.as_os_str().is_empty() {
146        return Err(takana_core::Error::Recipe(format!(
147            "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
148             cárgala con Recipe::load_from_path para resolver el catálogo",
149            recipe.name, recipe.deps.build
150        )));
151    }
152    let mut dirs = Vec::with_capacity(recipe.deps.build.len());
153    for dep in &recipe.deps.build {
154        let dep_path = resolve_dep_path(&recipe.base_dir, dep);
155        let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
156            takana_core::Error::Recipe(format!(
157                "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
158                recipe.name,
159                dep_path.display()
160            ))
161        })?;
162        let dep_hash = build(&dep_recipe, cfg, store)?;
163        dirs.push(store.path_of(&dep_hash, &dep_recipe.name));
164    }
165    Ok(dirs)
166}
167
168/// Resuelve el directorio de zig para una receta: el por defecto del lab (`cfg.zig_dir`), salvo que
169/// la receta fije `build.zig_version`, en cuyo caso se usa el zig de esa versión, ubicado como
170/// **hermano** del por defecto (`<…>/zig-x86_64-linux-<version>`, el layout de `.dev-fs/tools/`).
171/// Escotilla por-receta para esquivar la regresión de zig 0.14 sin tocar el toolchain global.
172fn effective_zig_dir(cfg: &BuildConfig, recipe: &Recipe) -> takana_core::Result<PathBuf> {
173    let Some(version) = &recipe.build.zig_version else {
174        return Ok(cfg.zig_dir.clone());
175    };
176    let parent = cfg.zig_dir.parent().unwrap_or_else(|| Path::new("."));
177    let dir = parent.join(format!("zig-x86_64-linux-{version}"));
178    if !dir.join("zig").is_file() {
179        return Err(takana_core::Error::Recipe(format!(
180            "zig_version=\"{version}\": no encuentro el ejecutable zig en {} \
181             (bajá ese zig a {}/)",
182            dir.display(),
183            parent.display(),
184        )));
185    }
186    Ok(dir)
187}
188
189/// Compila una receta y devuelve el hash del artefacto sellado en el store.
190/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
191pub fn build(
192    recipe: &Recipe,
193    cfg: &BuildConfig,
194    store: &Store,
195) -> takana_core::Result<ArtifactHash> {
196    // La huella sale del rootfs de ESTE cfg, que es el lab que de verdad va a compilar — no de
197    // `docs/state/lab-toolchain.lock` ni de una ruta derivada del store. Derivarla del padre del
198    // store ya mordió una vez (ver `BuildConfig::defaults_for_store_with_lab`): al anclar el store
199    // de un worker a un volumen, el lab se buscaba donde no estaba. `cfg.rootfs` respeta
200    // `TAKANA_LAB`/`TAKANA_ROOTFS` y es la única fuente que no puede desincronizarse del build.
201    let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?;
202    let h = artifact_hash(recipe, store, &lab)?;
203    if store.has(&h, &recipe.name) {
204        tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
205        return Ok(h);
206    }
207    // zig efectivo de ESTA receta: el por defecto del lab, salvo que la receta fije `zig_version`
208    // (escotilla para esquivar la regresión de zig 0.14 en ciertos binarios musl; ver SDD 11 §7.2b).
209    let zig_dir = effective_zig_dir(cfg, recipe)?;
210    sandbox::ensure_layout(&cfg.rootfs, &zig_dir)?;
211
212    // Materializa las deps de build (las construye si faltan) ANTES del fetch: sus árboles sellados
213    // se apilan como capas overlay del sandbox para que headers/libs/pkgconfig estén en /usr.
214    let dep_dirs = materialize_build_deps(recipe, cfg, store)?;
215    if !dep_dirs.is_empty() {
216        tracing::info!(n = dep_dirs.len(), "deps de build materializadas como capas overlay");
217    }
218
219    tracing::info!(hash = %h, name = %recipe.name, "fetch");
220    // ADR 0012 DECIDIDO (2026-09-22): el árbol de fuentes se toma con un lock EXCLUSIVO por receta
221    // que vive hasta el `seal` —`_guarda_arbol` es RAII—, no sólo durante la extracción. Un lock que
222    // cubriera nada más el `tar -x` no cierra la carrera: el árbol se muta después (patches,
223    // vendoreo) y se bindea para compilar. Ver la doc de `fetch::GuardaArbol` para por qué esto NO
224    // serializa la granja (dos recetas distintas no comparten su árbol) ni puede deadlockear (las
225    // deps se construyen ANTES, así que los locks no se anidan).
226    let (src_tree, _guarda_arbol) = fetch::fetch(recipe, &cfg.work_root)?;
227
228    // RE-CHEQUEO tras el lock (double-checked locking). Si dos procesos pedían la MISMA receta, el
229    // que esperó encuentra el artefacto ya sellado por el otro y sale por cache hit en vez de
230    // reconstruirlo — que además dejaría dos `seal` compitiendo por el mismo nombre en el store.
231    if store.has(&h, &recipe.name) {
232        tracing::info!(hash = %h, name = %recipe.name,
233                       "caché: otro build lo selló mientras esperábamos el árbol");
234        return Ok(h);
235    }
236
237    if !recipe.source.patches.is_empty() {
238        tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
239        fetch::apply_patches(recipe, &src_tree)?;
240    }
241
242    // Receta efectiva para resolver las FASES: puede ganar un `-p <pkg>` si la fuente es un
243    // workspace Cargo virtual (ver inject_cargo_package_selector). El hash `h` ya está fijado desde
244    // los flags ORIGINALES de la receta; el `-p` es resolución determinista del lab (misma fuente →
245    // mismo paquete) ⇒ no afecta la reproducibilidad ni el sidecar (que guarda la receta original).
246    let mut eff_recipe = recipe.clone();
247
248    // Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea
249    // hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock.
250    // `cargo_vendor = true` fuerza esta rama en árboles POLÍGLOTAS, donde `detect_build_system`
251    // —que asume un sistema por árbol— eligió el de C. librsvg es el arquetipo: `configure.ac` gana
252    // por precedencia, pero su Makefile llama a `cargo build`, y sin vendoreo ese cargo sale a la
253    // red dentro del sandbox hermético. Ver `Source::cargo_vendor`.
254    if matches!(detect_build_system(&src_tree), BuildSys::Cargo)
255        || eff_recipe.source.cargo_vendor == Some(true)
256    {
257        // El source se copia DENTRO del repo takana (que es un workspace Cargo) ⇒ si el Cargo.toml
258        // de la fuente NO declara su propio `[workspace]`, cargo cree que pertenece al workspace de
259        // takana y `cargo vendor` aborta. Inyectamos un `[workspace]` vacío para aislarla. SÓLO si
260        // no lo tiene ya (las recetas del corpus que lo parchean a mano siguen funcionando sin
261        // duplicar la tabla). Esto desbloquea genéricamente los imports Rust (Etapa G) sin un patch
262        // por receta. Ver memoria 'cargo-recipe crate suelto'.
263        ensure_cargo_workspace_isolation(&src_tree)?;
264        // Extractor de workspaces (Etapa G): si el root es virtual, resuelve `-p <pkg>` del bin.
265        // SÓLO cuando el árbol ES una receta Cargo. En un políglota (`cargo_vendor = true`) el
266        // producto lo arma el sistema de build de C —que llama a cargo con sus propios argumentos—
267        // y meterle un `-p` a los flags de la receta sería inyectar una opción de Cargo en un
268        // `./configure`. Acá el forzado pide vendoreo, nada más.
269        if matches!(detect_build_system(&src_tree), BuildSys::Cargo) {
270            inject_cargo_package_selector(&mut eff_recipe, &src_tree);
271        }
272        tracing::info!("cargo: vendoreando deps para build offline");
273        fetch::vendor_cargo_deps(
274            &src_tree,
275            eff_recipe.source.cargo_vendor_dir.as_deref().unwrap_or("vendor"),
276        )?;
277    }
278
279    // Recetas Go: análogo a Cargo. Si el árbol trae `go.mod`, vendoreamos los módulos (red en el
280    // fetch) para que `go build -mod=vendor` resuelva offline en el sandbox. El toolchain `go` lo
281    // aporta `deps.build = ["go"]` (recipes/go.toml) DENTRO del sandbox; el vendoring usa el `go`
282    // del host (lo provee el lab). No hay BuildSys::Go: las recetas Go fijan el compile en
283    // `[build.phases]` (ver tandas/staged-2026-06-26/amfora.toml como plantilla).
284    // El módulo Go puede estar en un subdir (recipe `build.subdir`, p.ej. dolt→go); vendoreamos ahí.
285    let go_root = if eff_recipe.build.subdir.is_empty() {
286        src_tree.clone()
287    } else {
288        src_tree.join(&eff_recipe.build.subdir)
289    };
290    if go_root.join("go.mod").is_file() || go_root.join("go.work").is_file() {
291        tracing::info!("go: vendoreando módulos para build offline");
292        fetch::vendor_go_deps(&go_root)?;
293    }
294
295    let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
296    let mut env: Vec<(String, String)> = Vec::new();
297    if matches!(recipe.build.link, LinkMode::Static) {
298        // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario
299        // monolítico que pide nuestra estrategia de hidratación primaria.
300        env.push(("LDFLAGS".into(), "-static".into()));
301    }
302    // El `compiler` de la receta decide el CC/CXX/AR reales; el default del sandbox es `zig cc`.
303    // Esto vuelve DECLARATIVO el escape a un toolchain real (gcc de Alpine = musl + GNU `ld`) para
304    // paquetes que el `lld` estricto de zig NO linkea (símbolos duplicados, flags que zig filtra —
305    // ver Etapa G, lz4). `self.env` pisa los defaults del sandbox. gcc/clang default a x86-64
306    // genérico (reproducible, sin `-march=native`), no hace falta `-mcpu=baseline`.
307    match recipe.build.compiler {
308        Compiler::ZigCc => {}
309        Compiler::Gcc => {
310            env.push(("CC".into(), "gcc".into()));
311            env.push(("CXX".into(), "g++".into()));
312            env.push(("AR".into(), "ar".into()));
313        }
314        Compiler::Clang => {
315            env.push(("CC".into(), "clang".into()));
316            env.push(("CXX".into(), "clang++".into()));
317            env.push(("AR".into(), "llvm-ar".into()));
318        }
319    }
320    // Autotools (Etapa G Fase 3): exporta CBUILD/CHOST con el triple nativo SANEADO del sandbox
321    // (`x86_64-linux-musl`, el mismo que el wrapper zig-cc emite). Las recetas importadas de Alpine
322    // traducen `./configure --build=$CBUILD --host=$CHOST` literal del abuild (`translate_abuild` los
323    // deja intactos a propósito); sin estas envs `$CBUILD/$CHOST` quedan VACÍAS y autotools cae a
324    // `config.guess`, que detecta `x86_64-alpine-linux-musl` — vendor que zig NO conoce
325    // (`UnknownOperatingSystem`). Resuelve el residuo autotools que antes era trabajo a mano. Inerte
326    // para Cargo/CMake/Meson (no leen estas envs).
327    env.push(("CBUILD".into(), SANDBOX_NATIVE_TARGET.to_string()));
328    env.push(("CHOST".into(), SANDBOX_NATIVE_TARGET.to_string()));
329    // *-sys C-FFI (Etapa G): si la receta declara `openssl` como dep, apuntá `openssl-sys` a la
330    // openssl takana-built materializada en /usr (estática, musl). Sin esto openssl-sys busca en el
331    // host y aborta ("Could not find directory of OpenSSL installation"). Condicional al dep ⇒ no
332    // rompe recetas que usen openssl vendored. Destraba la frontera cargo-* (reqwest/git tools).
333    if recipe.deps.build.iter().any(|d| d == "openssl") {
334        env.push(("OPENSSL_DIR".into(), "/usr".into()));
335        env.push(("OPENSSL_STATIC".into(), "1".into()));
336        env.push(("OPENSSL_NO_VENDOR".into(), "1".into()));
337    }
338    if let Some(cache) = &cfg.cache_root {
339        std::fs::create_dir_all(cache.join("zig"))?;
340    }
341    let sb = Sandbox {
342        rootfs: cfg.rootfs.clone(),
343        zig_dir: zig_dir.clone(),
344        src_dir: src_tree.clone(),
345        out_dir: out_dir.clone(),
346        cache_dir: cfg.cache_root.clone(),
347        deps: dep_dirs,
348        env,
349    };
350
351    let phases = resolve_phases(&eff_recipe, &src_tree)?;
352    if let Some(cmd) = &phases.configure {
353        tracing::info!(cmd, "phase: configure");
354        sb.run(cmd)?;
355    }
356    if let Some(cmd) = &phases.compile {
357        tracing::info!(cmd, "phase: compile");
358        sb.run(cmd)?;
359    } else {
360        return Err(takana_core::Error::Recipe(
361            "receta sin fase 'compile' y heurística no encontró ninguna".into(),
362        ));
363    }
364    if let Some(cmd) = &phases.install {
365        tracing::info!(cmd, "phase: install");
366        sb.run(cmd)?;
367    } else {
368        return Err(takana_core::Error::Recipe(
369            "receta sin fase 'install' y heurística no encontró ninguna".into(),
370        ));
371    }
372
373    // ── SEPARAR LA INFO DE DEPURACIÓN (SDD 23) ──────────────────────────────────────────────────
374    // Corre DESPUÉS del install, sobre `/out`, y sólo si la receta lo pide con `strip_debug = true`.
375    //
376    // POR QUÉ ACÁ Y NO EN LA FASE `install` DE CADA RECETA: de las 1161 recetas, **383 no tienen
377    // fase install explícita** — usan la heurística por defecto. Meterlo receta a receta obligaría a
378    // escribir a mano ese install por defecto en las 383, con el riesgo de no clavarlo exacto y
379    // cambiar el comportamiento sin querer. El lab lo hace una vez, igual para todas.
380    //
381    // ⚠ ESTO CAMBIA EL CONTENIDO DEL ARTEFACTO, así que el campo que lo activa **entra en
382    // `hash_inputs`** (ver la doc de `Build::strip_debug`). Si no entrara, el lab podría cambiar lo
383    // que sale del build sin mover el hash y el store MENTIRÍA.
384    //
385    // `zig objcopy` en vez del `strip` de binutils: zig SIEMPRE está en el sandbox (`/opt/zig`), así
386    // que activar esto no obliga a añadir una dep de build a cada receta.
387    //
388    // El `|| true` por fichero es deliberado: `/out` tiene scripts, datos y cabeceras además de ELF,
389    // y objcopy falla en ellos. Fallar el build por no poder strippear un `.h` sería absurdo; lo que
390    // importa es que los ELF queden sin `.debug_*`.
391    if recipe.build.strip_debug == Some(true) {
392        // ⚠ A FICHERO TEMPORAL Y LUEGO `mv` — NUNCA in-place. `zig objcopy --strip-debug X X`
393        // (mismo fichero como entrada y salida) **TRUNCA EL FICHERO A 0 BYTES**. El 2026-08-08 eso
394        // destruyó los artefactos del piloto, y lo peor es que los TRES indicadores decían que iba
395        // bien: el tamaño cayó un 84% (porque los ficheros quedaron vacíos), `why-differs` dijo
396        // REPRODUCE (porque dos árboles vacíos son idénticos) y no quedaban secciones `.debug_`
397        // (porque no quedaba ninguna sección). Se cazó al ejecutar el binario: 0 bytes.
398        // ⇒ La verificación de un artefacto tiene que incluir que SIGA FUNCIONANDO, no sólo que
399        //   pese menos y reproduzca. Un artefacto vacío cumple las dos y no sirve para nada.
400        // Herramienta: el `strip` de **binutils**, no `zig objcopy`. Se intentó con zig porque está
401        // siempre en el sandbox y no obligaría a declarar una dep — pero no se pudo confirmar que su
402        // `objcopy` acepte `--strip-debug`, y el resultado fue un no-op silencioso: los binarios
403        // quedaron intactos y el tamaño no bajó. Preferible una dep explícita que funciona a una
404        // comodidad que no se sabe si hace algo.
405        // ⇒ Las recetas con `strip_debug = true` deben declarar `binutils` en `[deps].build`.
406        //
407        // ⚠ `-D` (= `--enable-deterministic-archives`) NO ES OPCIONAL. Sin él, `strip` reescribe los
408        // `.a` con los timestamps/uid/gid reales de cada corrida ⇒ el artefacto sigue divergiendo,
409        // sólo que ahora por la CABECERA `ar` en vez de por `.debug_*`. Medido en el piloto: quitar
410        // el debug arregló una fuga de no-determinismo y abrió otra, y `why-differs` la nombró
411        // exacto («ar-cabecera → archivar en modo determinista»). Arreglar media causa deja el
412        // invariante igual de roto.
413        let cmd = r#"find /out -type f \( -perm -u+x -o -name '*.so' -o -name '*.so.*' -o -name '*.a' \) -exec sh -c 'strip -D --strip-debug "$1" 2>/dev/null || true' _ {} \;"#;
414        tracing::info!("phase: strip-debug (separando .debug_* del artefacto)");
415        sb.run(cmd)?;
416    }
417
418    // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del
419    // seal, para que quede congelada en el árbol read-only del store. `takana export` la
420    // lee de vuelta para emitir `source_patch` con la receta original en vez de un
421    // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4).
422    let sidecar_path = out_dir.join(takana_core::store::RECIPE_SIDECAR_REL);
423    if let Some(parent) = sidecar_path.parent() {
424        std::fs::create_dir_all(parent)?;
425    }
426    let toml_text = recipe.to_toml()?;
427    std::fs::write(&sidecar_path, toml_text.as_bytes())?;
428
429    let sealed = store.seal(&out_dir, &h, &recipe.name)?;
430    tracing::info!(path = %sealed.display(), "sealed");
431    Ok(h)
432}
433
434/// El resultado de correr un check de evidencia, con su veredicto (H1b).
435#[derive(Debug, Clone)]
436pub struct EvidenceOutcome {
437    pub kind: takana_core::EvidenceKind,
438    pub cmd: String,
439    pub outcome: takana_core::CheckOutcome,
440}
441
442/// Reporte de la ejecución de TODA la evidencia de una receta (H1b). `all_passed` es el gate:
443/// si es `false`, el bucle agéntico NO debe proponer la mutación (SDD 15 §H1: "falla ⇒ no se propone").
444#[derive(Debug, Clone)]
445pub struct EvidenceReport {
446    pub results: Vec<EvidenceOutcome>,
447}
448
449impl EvidenceReport {
450    pub fn all_passed(&self) -> bool {
451        self.results.iter().all(|r| r.outcome.passed)
452    }
453    /// El estrato de confianza MÁS ALTO que pasó (para reportar "verificado hasta <nivel>"). `None`
454    /// si no hay checks. No promete que ese nivel *baste* — sólo que lo declarado a ese nivel pasó.
455    pub fn max_level_passed(&self) -> Option<takana_core::EvidenceKind> {
456        self.results
457            .iter()
458            .filter(|r| r.outcome.passed)
459            .map(|r| r.kind)
460            .max()
461    }
462}
463
464/// Corre la evidencia de comportamiento de una receta (H1b, proof-carrying recipes). Reproduce el
465/// artefacto (cache-hit si ya está sellado), levanta un sandbox reproducible con la fuente +
466/// build-deps + el artefacto instalado montado como capa overlay (sus `/usr/bin` en PATH ⇒ los
467/// smoke tests tipo `bin --version` encuentran el binario), y ejecuta cada check comparando
468/// (exit, blake3(stdout)) contra lo declarado. Es "un runner de comandos con hash del output", no un
469/// framework — la confianza vive acá (pequeño y auditable), no en el generador de la receta.
470///
471/// Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta* (eso lo juzga el
472/// humano). Los checks corren con el entorno BASE del sandbox; un check que recompila (proptest) usa
473/// el toolchain que traigan las build-deps en PATH.
474pub fn run_evidence(
475    recipe: &Recipe,
476    cfg: &BuildConfig,
477    store: &Store,
478) -> takana_core::Result<EvidenceReport> {
479    recipe.evidence.validate()?;
480    if recipe.evidence.checks.is_empty() {
481        return Ok(EvidenceReport { results: Vec::new() });
482    }
483
484    // 1. Reproducir: construir/sellar el artefacto (cache-hit si existe).
485    let h = build(recipe, cfg, store)?;
486    let artifact_dir = store.path_of(&h, &recipe.name);
487
488    // 2. Preparar el sandbox: mismas capas que el build (rootfs + build-deps) MÁS el artefacto
489    //    instalado, para que sus binarios estén en /usr/bin (PATH). La fuente se re-materializa
490    //    para los checks que la necesitan (proptest/kani corren `cargo test` en /src).
491    let zig_dir = effective_zig_dir(cfg, recipe)?;
492    sandbox::ensure_layout(&cfg.rootfs, &zig_dir)?;
493    let mut dep_dirs = materialize_build_deps(recipe, cfg, store)?;
494    dep_dirs.push(artifact_dir);
495
496    // Misma guarda que el build: este camino re-materializa el árbol y le aplica los patches, o sea
497    // que MUTA estado compartido. Sin el lock, verificar la evidencia de una receta podía romper el
498    // árbol de un build en curso de la misma receta.
499    let (src_tree, _guarda_arbol) = fetch::fetch(recipe, &cfg.work_root)?;
500    if !recipe.source.patches.is_empty() {
501        fetch::apply_patches(recipe, &src_tree)?;
502    }
503    let out_dir = unique_out_dir(&cfg.work_root, &format!("{}-evidence", recipe.name), &h)?;
504
505    let sb = Sandbox {
506        rootfs: cfg.rootfs.clone(),
507        zig_dir,
508        src_dir: src_tree,
509        out_dir,
510        cache_dir: cfg.cache_root.clone(),
511        deps: dep_dirs,
512        env: Vec::new(),
513    };
514
515    // 3. Ejecutar cada check y evaluarlo (la comparación exit/hash es pura, vive en takana-core).
516    let mut results = Vec::with_capacity(recipe.evidence.checks.len());
517    for c in &recipe.evidence.checks {
518        tracing::info!(kind = c.kind.as_str(), cmd = %c.cmd, "evidence: check");
519        let out = sb.run_capture(&c.cmd)?;
520        let outcome = c.evaluate(out.exit_code, &out.stdout);
521        tracing::info!(passed = outcome.passed, detail = %outcome.detail, "evidence: veredicto");
522        results.push(EvidenceOutcome {
523            kind: c.kind,
524            cmd: c.cmd.clone(),
525            outcome,
526        });
527    }
528    Ok(EvidenceReport { results })
529}
530
531/// Sistemas de build que reconoce la heurística por inspección del árbol del source.
532/// Ver `docs/02-build-lab.md` §4.
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534enum BuildSys {
535    /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero.
536    AutoconfRaw,
537    /// `configure` ya está en el tarball release: vamos directo a `./configure`.
538    AutoconfReady,
539    /// `CMakeLists.txt`.
540    CMake,
541    /// `meson.build`.
542    Meson,
543    /// `Cargo.toml`: crate o workspace Rust. Build con `cargo`, link con `zig cc`.
544    Cargo,
545    /// `go.mod`: módulo Go. Build con `go install` del paquete main (auto-detectado),
546    /// deps vendoreadas en el fetch (`vendor_go_deps`). El toolchain `go` llega por deps.build.
547    Go,
548    /// `Makefile` plano (o `makefile`) sin sistema generador.
549    Make,
550    /// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
551    Unknown,
552}
553
554/// ¿El `Cargo.toml` raíz es un MANIFIESTO VIRTUAL? (declara `[workspace]` pero NO `[package]`).
555/// Es el patrón del ecosistema CLI Rust moderno (sd, fd, …): el root sólo agrupa miembros y el
556/// binario vive en un sub-paquete. En ese caso `cargo … --bin X` solo falla: hace falta `-p <pkg>`.
557/// Un crate suelto (con `[package]`, aunque `ensure_cargo_workspace_isolation` le agregue
558/// `[workspace]`) NO es virtual ⇒ no se toca (ripgrep y el corpus quedan idénticos).
559fn cargo_root_is_virtual(src: &Path) -> bool {
560    let Ok(text) = std::fs::read_to_string(src.join("Cargo.toml")) else {
561        return false;
562    };
563    let mut has_ws = false;
564    let mut has_pkg = false;
565    for l in text.lines() {
566        let t = l.trim_start();
567        if t.starts_with("[workspace]") || t.starts_with("[workspace.") {
568            has_ws = true;
569        }
570        if t.starts_with("[package]") {
571            has_pkg = true;
572        }
573    }
574    has_ws && !has_pkg
575}
576
577/// Lee los MEMBERS del workspace (vía `cargo metadata`, la fuente autoritativa: expande globs,
578/// `[[bin]]`, bins por convención `src/bin/*.rs`, y workspaces anidados — todo lo que un parseo a
579/// mano del TOML erraría) y devuelve el NOMBRE del paquete que expone un bin llamado `bin`. Es el
580/// "extractor de workspaces" de la Etapa G: convierte un workspace virtual en un `-p <pkg>` exacto.
581fn resolve_cargo_bin_package(src: &Path, bin: &str) -> Option<String> {
582    let out = std::process::Command::new("cargo")
583        .args(["metadata", "--no-deps", "--format-version", "1"])
584        .current_dir(src)
585        .output()
586        .ok()?;
587    if !out.status.success() {
588        return None;
589    }
590    let meta: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
591    for pkg in meta.get("packages")?.as_array()? {
592        let pkg_name = pkg.get("name")?.as_str()?;
593        for tgt in pkg.get("targets")?.as_array()? {
594            let is_bin = tgt
595                .get("kind")
596                .and_then(|k| k.as_array())
597                .map(|ks| ks.iter().any(|x| x.as_str() == Some("bin")))
598                .unwrap_or(false);
599            if is_bin && tgt.get("name").and_then(|n| n.as_str()) == Some(bin) {
600                return Some(pkg_name.to_string());
601            }
602        }
603    }
604    None
605}
606
607/// Si la fuente es un workspace VIRTUAL y los flags piden `--bin X` sin `-p`, resuelve el paquete
608/// real que expone `X` y antepone `-p <pkg>`. Deja los flags intactos en cualquier otro caso
609/// (crate suelto, ya con `-p`, o bin irresoluble). Determinista (misma fuente → mismo paquete) ⇒
610/// reproducible. Sin esto, casi todo el CLI Rust moderno (workspaces virtuales) exigía `-p` a mano.
611fn inject_cargo_package_selector(recipe: &mut Recipe, src: &Path) {
612    let flags = &recipe.build.flags;
613    if flags.iter().any(|f| f == "-p" || f == "--package") {
614        return; // el autor ya eligió el paquete
615    }
616    if !cargo_root_is_virtual(src) {
617        return; // crate suelto: `--bin` solo alcanza
618    }
619    // bin = el argumento que sigue a `--bin`.
620    let Some(bin) = flags.iter().position(|f| f == "--bin").and_then(|i| flags.get(i + 1)).cloned()
621    else {
622        return;
623    };
624    if let Some(pkg) = resolve_cargo_bin_package(src, &bin) {
625        tracing::info!(%bin, %pkg, "cargo: workspace virtual ⇒ -p resuelto automáticamente");
626        let mut new_flags = vec!["-p".to_string(), pkg];
627        new_flags.extend(recipe.build.flags.iter().cloned());
628        recipe.build.flags = new_flags;
629    }
630}
631
632/// Garantiza que el `Cargo.toml` raíz de la fuente declare un `[workspace]` propio, para que cargo
633/// NO la considere parte del workspace del repo takana (que la contiene en `work/sources/`). Append
634/// idempotente: si ya hay `[workspace]` (crate suelto que es su propio workspace, o una receta que
635/// lo parchea), no toca nada. Caso virtual-manifest (root ya es `[workspace]`) tampoco se toca.
636fn ensure_cargo_workspace_isolation(src: &Path) -> takana_core::Result<()> {
637    let manifest = src.join("Cargo.toml");
638    let text = std::fs::read_to_string(&manifest)?;
639    // Heurística simple: ¿hay una tabla `[workspace]` (al inicio de línea)? Cubre `[workspace]` y
640    // `[workspace.members]`/`[workspace.dependencies]` (un root virtual siempre trae alguna).
641    let has_workspace = text
642        .lines()
643        .any(|l| l.trim_start().starts_with("[workspace]") || l.trim_start().starts_with("[workspace."));
644    if !has_workspace {
645        let mut patched = text;
646        if !patched.ends_with('\n') {
647            patched.push('\n');
648        }
649        patched.push_str("\n[workspace]\n");
650        std::fs::write(&manifest, patched)?;
651        tracing::info!("cargo: inyectado [workspace] vacío para aislar la fuente del repo hammer");
652    }
653    Ok(())
654}
655
656fn detect_build_system(src: &Path) -> BuildSys {
657    let has = |p: &str| src.join(p).exists();
658    // `go.mod`/`go.work` PRIMERO: un módulo Go suele traer también un `configure`/`Makefile` AUXILIAR
659    // (man pages, completions, packaging), pero el binario se hace con `go build`. Ningún paquete
660    // C/Rust del corpus trae go.mod. `go.work` cubre los WORKSPACE multimódulo cuya RAÍZ no tiene
661    // go.mod (sólo go.work + submódulos) — sin esto caían a Makefile/Unknown.
662    if has("go.mod") || has("go.work") {
663        return BuildSys::Go;
664    }
665    // El orden importa: cuando coexisten varios (un tarball release de autotools trae el
666    // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el
667    // sistema "más alto" que sí sabe regenerar todo.
668    if has("configure.ac") || has("configure.in") {
669        if has("configure") {
670            return BuildSys::AutoconfReady;
671        }
672        return BuildSys::AutoconfRaw;
673    }
674    if has("configure") {
675        return BuildSys::AutoconfReady;
676    }
677    if has("CMakeLists.txt") {
678        return BuildSys::CMake;
679    }
680    if has("meson.build") {
681        return BuildSys::Meson;
682    }
683    // Cargo gana sobre un `Makefile` huérfano (algunos crates traen un wrapper `make`),
684    // pero cede ante autotools/cmake/meson (un políglota C+Rust suele querer el sistema C).
685    if has("Cargo.toml") {
686        return BuildSys::Cargo;
687    }
688    if has("Makefile") || has("makefile") || has("GNUmakefile") {
689        return BuildSys::Make;
690    }
691    BuildSys::Unknown
692}
693
694/// Detecta el directorio del paquete `main` PRINCIPAL de un módulo Go, relativo a `src`, para
695/// `go install`. Busca por FILESYSTEM (no compila ⇒ no se contamina con mains de ejemplo rotos,
696/// como los de `docs/`/`scripts/` que rompen `go list ./...`): archivos `.go` cuya primera línea
697/// significativa es `package main`, descartando dirs auxiliares (vendor/docs/examples/tests/
698/// scripts/tools/internal y ocultos/`_`). Elige el principal por heurística: raíz > `cmd/<x>` >
699/// menor profundidad. Devuelve un path estilo go (`.`, `./cmd/mlr`). Una receta puede forzar el
700/// paquete vía `build.flags` (gana sobre la auto-detección).
701fn detect_go_main(src: &Path, pkg_name: &str) -> String {
702    fn is_main_go(path: &Path) -> bool {
703        let Ok(text) = std::fs::read_to_string(path) else { return false };
704        // El primer token significativo de un .go es la cláusula `package` (tras build-constraints
705        // `//go:build`, comentarios de línea y el header de licencia). Hay que saltar comentarios de
706        // BLOQUE `/* ... */` correctamente: muchos headers Apache son `/*\nCopyright ...\n*/` y las
707        // líneas internas NO arrancan con `*` ⇒ un filtro por-línea ingenuo cortaría ahí y fallaría
708        // (p.ej. cmd/helm/helm.go). Escaneamos rastreando el estado dentro/fuera de bloque.
709        let mut in_block = false;
710        for line in text.lines() {
711            let mut l = line.trim();
712            if in_block {
713                match l.find("*/") {
714                    Some(i) => {
715                        l = l[i + 2..].trim();
716                        in_block = false;
717                    }
718                    None => continue,
719                }
720            }
721            if l.is_empty() || l.starts_with("//") {
722                continue;
723            }
724            if let Some(i) = l.find("/*") {
725                let before = l[..i].trim();
726                if !before.is_empty() {
727                    return before.starts_with("package main");
728                }
729                match l[i + 2..].find("*/") {
730                    Some(j) => {
731                        let after = l[i + 2 + j + 2..].trim();
732                        if after.is_empty() {
733                            continue;
734                        }
735                        return after.starts_with("package main");
736                    }
737                    None => {
738                        in_block = true;
739                        continue;
740                    }
741                }
742            }
743            return l.starts_with("package main");
744        }
745        false
746    }
747    const SKIP: &[&str] = &[
748        "vendor", "docs", "doc", "example", "examples", "test", "tests", "testdata", "script",
749        "scripts", "tools", "internal", "third_party",
750    ];
751    let mut found: Vec<PathBuf> = Vec::new();
752    let mut stack = vec![src.to_path_buf()];
753    while let Some(dir) = stack.pop() {
754        let Ok(entries) = std::fs::read_dir(&dir) else { continue };
755        let mut has_main = false;
756        for e in entries.flatten() {
757            let p = e.path();
758            if p.is_dir() {
759                let name = e.file_name().to_string_lossy().to_string();
760                if name.starts_with('.') || name.starts_with('_') || SKIP.contains(&name.as_str()) {
761                    continue;
762                }
763                stack.push(p);
764            } else if !has_main
765                && p.extension().map(|x| x == "go").unwrap_or(false)
766                && !p.to_string_lossy().ends_with("_test.go")
767                && is_main_go(&p)
768            {
769                has_main = true;
770            }
771        }
772        if has_main {
773            if let Ok(rel) = dir.strip_prefix(src) {
774                found.push(rel.to_path_buf());
775            }
776        }
777    }
778    // raíz (prioridad 0) > cmd/<x> (1) > resto (2); a igual clase, el dir cuyo nombre coincide con el
779    // de la receta (p.ej. cmd/eksctl para la receta `eksctl`) gana sobre otros mains hermanos (cmd/
780    // schema, un helper de codegen que panica) — sin esto detect elegía arbitrariamente entre varios
781    // mains y sellaba el binario EQUIVOCADO; luego, a igual nombre, menor profundidad.
782    let key = |p: &PathBuf| -> (u8, u8, usize) {
783        let s = p.to_string_lossy();
784        let depth = p.components().count();
785        let name_match = if p.file_name().map(|f| f == pkg_name).unwrap_or(false) { 0 } else { 1 };
786        let class = if s.is_empty() {
787            0
788        } else if s.starts_with("cmd/") || s == "cmd" {
789            1
790        } else {
791            2
792        };
793        (class, name_match, depth)
794    };
795    found.sort_by_key(key);
796    match found.first() {
797        Some(p) if !p.as_os_str().is_empty() => format!("./{}", p.to_string_lossy()),
798        _ => ".".to_string(),
799    }
800}
801
802/// Target nativo del sandbox del lab (Alpine x86_64 musl). Una receta Cargo cuyo `build.target`
803/// sea éste se construye **nativa** (sin `--target`): el rust del sandbox sólo trae su std nativo
804/// (`x86_64-alpine-linux-musl`), no el `x86_64-unknown-linux-musl` que pediría un cross, y no hay
805/// rustup para agregarlo. Ver `docs/runbooks/stage1-vm-boot.md` §8 (primera corrida).
806const SANDBOX_NATIVE_TARGET: &str = "x86_64-linux-musl";
807
808/// Traduce el triple estilo zig/hammer (`x86_64-linux-musl`) al de rustc
809/// (`x86_64-unknown-linux-musl`). Un triple ya completo (con vendor) o ajeno al patrón
810/// `<arch>-linux-<libc>` se devuelve tal cual: la receta puede dar el triple exacto.
811fn rustc_triple(target: &str) -> String {
812    match target.split('-').collect::<Vec<_>>().as_slice() {
813        [arch, "linux", libc] => format!("{arch}-unknown-linux-{libc}"),
814        _ => target.to_string(),
815    }
816}
817
818/// Nombre de la env `CARGO_TARGET_<TRIPLE>_LINKER` para un triple de rustc.
819fn cargo_linker_env_var(triple: &str) -> String {
820    format!("CARGO_TARGET_{}_LINKER", triple.to_uppercase().replace(['-', '.'], "_"))
821}
822
823/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta
824/// ganan; el resto lo deriva la heurística según el sistema de build detectado.
825fn resolve_phases(recipe: &Recipe, src: &Path) -> takana_core::Result<Phases> {
826    let mut out = recipe.build.phases.clone();
827    // Raíz efectiva del módulo Go: `<src>/<subdir>` si la receta lo fija (dolt→go, csvtk→subdir),
828    // si no `src`. detect/detect_go_main operan ahí; el compile hace `cd <subdir>` primero.
829    let go_src = if recipe.build.subdir.is_empty() {
830        src.to_path_buf()
831    } else {
832        src.join(&recipe.build.subdir)
833    };
834    let bs = detect_build_system(&go_src);
835    let flags = recipe.build.flags.join(" ");
836    let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") };
837
838    // Autotools (Etapa G Fase 3): fija `--build`/`--host` al triple nativo saneado para que
839    // `config.guess` no detecte el vendor `alpine` que zig rechaza, e iguala lo que el wrapper zig-cc
840    // produce. Sólo si la receta no los puso ya en sus flags (juicio per-paquete gana). build==host
841    // ⇒ NATIVO (no cross): autotools sigue corriendo sus AC_RUN tests; sólo normaliza el triple.
842    let autotools_triple = if recipe.build.flags.iter().any(|f| {
843        f.starts_with("--build=") || f.starts_with("--host=")
844    }) {
845        String::new()
846    } else {
847        format!(" --build={SANDBOX_NATIVE_TARGET} --host={SANDBOX_NATIVE_TARGET}")
848    };
849
850    if out.configure.is_none() {
851        out.configure = match bs {
852            BuildSys::AutoconfRaw => Some(format!(
853                "autoreconf -fi && ./configure --prefix=/usr{autotools_triple}{flags_suffix}"
854            )),
855            BuildSys::AutoconfReady => {
856                Some(format!("./configure --prefix=/usr{autotools_triple}{flags_suffix}"))
857            }
858            BuildSys::CMake => Some(format!(
859                "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}"
860            )),
861            BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")),
862            // Cargo/Go no tienen fase configure separada.
863            BuildSys::Cargo | BuildSys::Go | BuildSys::Make | BuildSys::Unknown => None,
864        };
865    }
866    if out.compile.is_none() {
867        out.compile = match bs {
868            BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
869                Some(r#"make -j"$(nproc)""#.to_string())
870            }
871            BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()),
872            BuildSys::Meson => Some("meson compile -C _build".to_string()),
873            BuildSys::Cargo => {
874                // Hermético y reproducible: `--offline` (deps vendoreadas en la fuente) +
875                // `--locked` (Cargo.lock fijo, coherente con ADR 0006). El link va por un
876                // wrapper `zig cc` — cargo no acepta un linker de dos palabras, y zig provee
877                // el `libgcc_s` que el link dinámico musl pide (ver M2 del plan arje↔takana).
878                if recipe.build.target == SANDBOX_NATIVE_TARGET {
879                    // NATIVO: el sandbox del lab YA es x86_64 musl, así que no cruzamos. El rust del
880                    // sandbox sólo trae su std nativo (`x86_64-alpine-linux-musl`), no
881                    // `x86_64-unknown-linux-musl`; con `--target` fallaría "can't find crate for
882                    // core" (no hay rustup). Ver docs/runbooks/stage1-vm-boot.md §8.
883                    //
884                    // El wrapper `.hammer-zig-cc` hace DOS cosas: (1) es un único ejecutable, lo que
885                    // resuelve el `zig cc` de dos palabras que cargo (linker) y cc-rs (CC)
886                    // mal-parsean; (2) **sanea el triple**: cc-rs (build scripts que compilan C/asm,
887                    // p.ej. blake3) detecta el host triple de Alpine rust y pasa
888                    // `--target=x86_64-alpine-linux-musl`, pero zig no conoce el vendor "alpine"
889                    // (`UnknownOperatingSystem`); lo reescribimos a `x86_64-linux-musl`. Se usa como
890                    // CC (cc-rs) y como linker (RUSTFLAGS, target-agnóstico — el default sería
891                    // cc/gcc, ausente en el sandbox zig-only).
892                    // Flags de codegen del BINARIO FINAL, vía `cargo rustc -- …` (NO RUSTFLAGS): en
893                    // build nativo RUSTFLAGS también alcanza a los proc-macros (deps), y
894                    // `+crt-static`/`relocation-model=static` los rompe ("cannot produce
895                    // proc-macro" — un proc-macro es un dylib PIC). `cargo rustc -- …` los aplica
896                    // sólo a la crate top-level, dejando los proc-macros intactos.
897                    let bin_flags = match recipe.build.link {
898                        // Estático AUTOCONTENIDO y no-PIE: `+crt-static` mete musl dentro (sin
899                        // DT_NEEDED — el rust de Alpine enlaza musl dinámico por defecto, lo que deja
900                        // `libc.musl-x86_64.so.1` y rompe el boot, runbook §8); `relocation-model=static`
901                        // ⇒ ET_EXEC sin interpreter (vía de oro: como busybox, sin libc.so/loader/soname).
902                        LinkMode::Static => "-C target-feature=+crt-static -C relocation-model=static",
903                        // Dinámico: apaga crt-static para poder dlopen (necesita el loader/musl en el rootfs).
904                        LinkMode::Dynamic => "-C target-feature=-crt-static",
905                    };
906                    // `-C link-self-contained=no` CONDICIONAL: el link (de TODO — deps,
907                    // build-scripts, proc-macros y la crate top) lo hace `zig cc`, que ya aporta
908                    // sus startfiles (crt1.o). Un rust VANILLA con target musl
909                    // `x86_64-unknown-linux-musl` trae CRT AUTOCONTENIDO (self-contained/rcrt1.o) y
910                    // lo pasaría también ⇒ `ld.lld: duplicate symbol: _start`; hay que apagarlo. PERO
911                    // el rust de Alpine (host `x86_64-alpine-linux-musl`) PARCHEA la opción para que
912                    // sea un ERROR ("option `-C link-self-contained` is not supported on this
913                    // target") y NO usa self-contained — el baseline 9adefb82 linkea con zig sin
914                    // chocar. Así que: añadir el flag SÓLO si el host del rustc NO es `-alpine-`
915                    // (i.e. un rust vanilla como el takana-rust del frente self-host); para Alpine,
916                    // omitirlo. RUSTFLAGS alcanza a todas las unidades; los `bin_flags` (cargo rustc
917                    // --) sólo a la top.
918                    // CC para los build-scripts (cc-rs compila el C de deps como libgit2-sys,
919                    // openssl-sys, libc-stdhandle). Por defecto = el wrapper zig-cc. PERO zig-cc
920                    // MISCOMPILA/RECHAZA muchos de esos C (mismo frente que file/jq en C puro: la
921                    // granja lo reveló — delta/gitui/jless mueren ahí). Con `compiler="gcc"` la receta
922                    // declara el GUETO: cc-rs usa gcc (el Alpine musl gcc del sandbox, probado en C),
923                    // y el linker sigue siendo zig-cc — los .o/.a de gcc-musl y el Rust zig-musl son
924                    // ABI-compatibles (ambos musl, mismo ELF x86-64). Aditivo: una receta Rust sin
925                    // `compiler=` queda en ZigCc ⇒ baseline (ripgrep/fd/…) intacto.
926                    let cc_for_scripts = match recipe.build.compiler {
927                        Compiler::Gcc => "gcc",
928                        Compiler::Clang => "clang",
929                        Compiler::ZigCc => "$PWD/.hammer-zig-cc",
930                    };
931                    Some(format!(
932                        "printf '%s\\n' '#!/bin/sh' 'for a do' \
933                         'case \"$a\" in --target=*) a=--target=x86_64-linux-musl ;; esac' \
934                         'set -- \"$@\" \"$a\"' 'shift' 'done' 'exec zig cc -mcpu=baseline \"$@\"' \
935                         > \"$PWD/.hammer-zig-cc\" && chmod +x \"$PWD/.hammer-zig-cc\" && \
936                         RF=\"-C linker=$PWD/.hammer-zig-cc\"; \
937                         rustc -vV | grep -q 'host: .*-alpine-' || RF=\"$RF -C link-self-contained=no\"; \
938                         CC=\"{cc_for_scripts}\" RUSTFLAGS=\"$RF\" \
939                         cargo rustc --release --locked --offline{flags_suffix} -- {bin_flags}"
940                    ))
941                } else {
942                    // CROSS real (otro target que zig sí entiende): `--target` + linker por env
943                    // per-triple. Hermético y reproducible: `--offline` (deps vendoreadas) +
944                    // `--locked` (Cargo.lock fijo, ADR 0006). zig provee el `libgcc_s` que el link
945                    // dinámico musl pide (ver M2 del plan arje↔takana).
946                    let setup = "printf '#!/bin/sh\\nexec zig cc -mcpu=baseline \"$@\"\\n' > \"$PWD/.hammer-zig-cc\" && \
947                                 chmod +x \"$PWD/.hammer-zig-cc\" && ";
948                    let triple = rustc_triple(&recipe.build.target);
949                    let linker_var = cargo_linker_env_var(&triple);
950                    let rustflags = match recipe.build.link {
951                        // musl es crt-static por defecto; el dinámico lo apaga para poder dlopen.
952                        LinkMode::Dynamic => "RUSTFLAGS='-C target-feature=-crt-static' ",
953                        LinkMode::Static => "",
954                    };
955                    Some(format!(
956                        "{setup}{rustflags}{linker_var}=\"$PWD/.hammer-zig-cc\" \
957                         cargo build --release --locked --offline --target {triple}{flags_suffix}"
958                    ))
959                }
960            }
961            BuildSys::Go => {
962                // Un solo patrón GENÉRICO para todo proyecto Go: `go install` del paquete main
963                // auto-detectado (o el que fije `build.flags`). go install nombra el binario solo
964                // (cmd/mlr→mlr, raíz→<módulo>) y lo deja en GOBIN=/out/usr/bin ⇒ no hace falta saber
965                // el nombre. Offline+reproducible: vendor (vendor_go_deps) + GOPROXY=off + -trimpath
966                // -buildid=. El toolchain `go` llega por deps.build=["go"] (overlay).
967                let pkg = if recipe.build.flags.is_empty() {
968                    detect_go_main(&go_src, &recipe.name)
969                } else {
970                    flags.clone()
971                };
972                // Si el módulo está en subdir, el compile entra ahí primero (el sandbox arranca en /src).
973                let cd_prefix = if recipe.build.subdir.is_empty() {
974                    String::new()
975                } else {
976                    format!("cd {} && ", recipe.build.subdir)
977                };
978                // GOCACHE/GOTMPDIR en /src (disk-backed, per-build, lo limpia el watchdog), NO en
979                // /tmp: el sandbox monta `--tmpfs /tmp` (~½ RAM); un proyecto Go ENORME (telegraf,
980                // vault) desborda ese tmpfs con sus objetos compilados + el temp del linker al mapear
981                // el binario → "link: mapping output file failed: no space left on device" aunque el
982                // disco tenga sitio. /src es el árbol de fuentes bind-montado (en disco) y no se sella
983                // (sólo /out) ⇒ inocuo. GOTMPDIR debe pre-existir. No entra al hash de input (es la
984                // fase generada, no un campo de la receta) ⇒ no re-sella el corpus Go.
985                // CGO opt-in (recipe `cgo = true`): drivers con C bundled (sqlite3 de mattn en
986                // usql/sq/dolt) necesitan cgo. CC="zig cc" (musl nativo del sandbox) + link estático
987                // por el linker externo (`-linkmode=external -extldflags=-static`). Default: CGO off
988                // (Go puro estático). Validado: usql (sqlite, estático y corre).
989                let (cgo_env, ldflags) = if recipe.build.cgo {
990                    (
991                        // ⚠ `-mcpu=baseline` NO es opcional y este sitio lo perdía: el sandbox
992                        // exporta `CC` apuntando a un wrapper que ya lo pone (ver `sandbox.rs`), y
993                        // esta línea lo PISABA con `zig cc` a secas — que por defecto es
994                        // `-mcpu=native` y hornea la ISA del builder. El síntoma no aparece al
995                        // compilar ni al sellar: aparece al EJECUTAR en otra CPU, como
996                        // `trap invalid opcode` (SIGILL). Medido 2026-09-14 con `gitea` dentro de
997                        // QEMU-TCG: el binario construido en el worker moría al arrancar, y el
998                        // mismo con baseline corre. Afecta a toda receta `cgo = true`.
999                        "CGO_ENABLED=1 CC=\"zig cc -mcpu=baseline\" CXX=\"zig c++ -mcpu=baseline\"",
1000                        "-ldflags=\"-buildid= -linkmode=external -extldflags=-static\"",
1001                    )
1002                } else {
1003                    ("CGO_ENABLED=0", "-ldflags=-buildid=")
1004                };
1005                // ── GOTMPDIR: DENTRO del módulo con cgo apagado, FUERA con cgo encendido ──────
1006                // `-trimpath` reescribe rutas, pero cuál gana depende de dónde esté el work dir:
1007                //   · si cae DENTRO del módulo, gana la reescritura del módulo y el componente
1008                //     ALEATORIO `go-build<N>` SOBREVIVE;
1009                //   · si cae FUERA, Go reescribe el work dir entero a `/tmp/go-build` y el número
1010                //     desaparece.
1011                // Con CGO off da igual: no hay fuentes C generadas, así que ninguna ruta del work
1012                // dir entra en el DWARF — las 15 recetas Go puras verificadas REPRODUCEN. Con cgo
1013                // sí entra, y ésa era la ÚNICA causa del no-determinismo de `gocryptfs`: dos
1014                // reconstrucciones difieren en SIETE bytes, todos en `.debug_str`, y son los
1015                // dígitos de `go-build598798091` contra `go-build270591055`.
1016                //
1017                // Medido con un módulo cgo de juguete, cachés limpias y en los dos sentidos
1018                // (2026-09-14):
1019                //   · GOTMPDIR dentro  ⇒ `go-build3806217352` aparece en el binario; dos builds DIFIEREN
1020                //   · GOTMPDIR fuera   ⇒ ninguna cadena `go-build`; dos builds IDÉNTICOS
1021                //
1022                // ⚠ Va a `/cache`, NO a `/tmp`: el sandbox monta `--tmpfs /tmp` (~½ RAM) y un
1023                // proyecto Go grande lo desborda con sus objetos — es la razón por la que esto
1024                // estaba en `/src` de entrada. `/cache` es un bind RW a disco (el mismo que usa la
1025                // caché de zig), así que conserva la propiedad de estar en disco Y queda fuera del
1026                // módulo.
1027                //
1028                // ⚠ Y se COMPRUEBA que `/cache` exista en vez de dar por hecho que está: con
1029                // `--tmp-overlay /` la raíz es escribible, así que un `mkdir -p /cache/gotmp` a
1030                // ciegas TRIUNFARÍA creando el directorio en la capa tmpfs — o sea en RAM, que es
1031                // exactamente el desbordamiento que se quería evitar, y en silencio. Si no está,
1032                // el build muere diciendo por qué.
1033                let gotmp_setup = if recipe.build.cgo {
1034                    "[ -d /cache ] || { echo '!! falta el bind /cache: GOTMPDIR caería en la capa                      tmpfs (RAM) y un proyecto Go grande la desborda' >&2; exit 1; };                      mkdir -p /cache/gotmp"
1035                } else {
1036                    "mkdir -p /src/.gotmp"
1037                };
1038                let gotmpdir = if recipe.build.cgo { "/cache/gotmp" } else { "/src/.gotmp" };
1039                Some(format!(
1040                    "{cd_prefix}export GOCACHE=/src/.gocache GOTMPDIR={gotmpdir} GOPATH=/tmp/gopath \
1041                     GOTOOLCHAIN=local {cgo_env} GOFLAGS=-mod=vendor GOPROXY=off \
1042                     GOBIN=/out/usr/bin; {gotmp_setup}; \
1043                     go install -trimpath {ldflags} {pkg}"
1044                ))
1045            }
1046            BuildSys::Unknown => None,
1047        };
1048    }
1049    if out.install.is_none() {
1050        out.install = match bs {
1051            BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
1052                Some("make install DESTDIR=/out PREFIX=/usr".to_string())
1053            }
1054            BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()),
1055            BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()),
1056            BuildSys::Cargo => {
1057                // Copia los ejecutables finales (top del dir release, bit de ejecución) a
1058                // /out/usr/bin. `-perm -100` / `-exec cp \;` son busybox-safe (rootfs Alpine).
1059                // Build nativo ⇒ `target/release`; cross ⇒ `target/<triple>/release`.
1060                let rel = if recipe.build.target == SANDBOX_NATIVE_TARGET {
1061                    "target/release".to_string()
1062                } else {
1063                    format!("target/{}/release", rustc_triple(&recipe.build.target))
1064                };
1065                Some(format!(
1066                    "mkdir -p /out/usr/bin && \
1067                     find {rel} -maxdepth 1 -type f -perm -100 \
1068                     -exec cp {{}} /out/usr/bin/ \\;"
1069                ))
1070            }
1071            // `go install` ya dejó los binarios en GOBIN=/out/usr/bin: nada que hacer.
1072            BuildSys::Go => Some("true".to_string()),
1073            BuildSys::Unknown => None,
1074        };
1075    }
1076    Ok(out)
1077}
1078
1079#[cfg(test)]
1080mod heuristic_tests {
1081    use super::*;
1082
1083    fn touch(dir: &Path, name: &str) {
1084        std::fs::write(dir.join(name), b"").unwrap();
1085    }
1086
1087    fn recipe(flags: &[&str]) -> Recipe {
1088        let mut r = Recipe::from_toml(
1089            r#"
1090name = "x"
1091version = "0"
1092[source]
1093repo = "git://x"
1094commit = "deadbeef"
1095[build]
1096"#,
1097        )
1098        .unwrap();
1099        r.build.flags = flags.iter().map(|s| (*s).to_string()).collect();
1100        r
1101    }
1102
1103    #[test]
1104    fn go_subdir_compiles_in_subdir() {
1105        // dolt-like: go.mod en subdir `go/`. Con build.subdir="go" el lab detecta Go ahí y el compile
1106        // entra al subdir primero.
1107        let d = tempfile::tempdir().unwrap();
1108        std::fs::create_dir_all(d.path().join("go/cmd/dolt")).unwrap();
1109        touch(d.path(), "go/go.mod");
1110        std::fs::write(d.path().join("go/cmd/dolt/main.go"), b"package main\nfunc main(){}\n").unwrap();
1111        let mut r = recipe(&[]);
1112        r.name = "dolt".into();
1113        r.build.subdir = "go".into();
1114        let p = resolve_phases(&r, d.path()).unwrap();
1115        let c = p.compile.unwrap();
1116        assert!(c.starts_with("cd go && "), "entra al subdir: {c}");
1117        assert!(c.contains("go install -trimpath") && c.trim_end().ends_with("./cmd/dolt"), "{c}");
1118    }
1119
1120    #[test]
1121    fn go_mod_wins_over_configure_and_derives_go_install() {
1122        // miller-like: go.mod + configure auxiliar ⇒ Go gana, y el compile es `go install`.
1123        let d = tempfile::tempdir().unwrap();
1124        touch(d.path(), "go.mod");
1125        touch(d.path(), "configure");
1126        std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap();
1127        assert_eq!(detect_build_system(d.path()), BuildSys::Go);
1128        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1129        assert!(p.configure.is_none());
1130        let c = p.compile.unwrap();
1131        assert!(c.contains("go install -trimpath"), "{c}");
1132        assert!(c.ends_with(" ."), "raíz ⇒ paquete `.`: {c}");
1133        assert_eq!(p.install.as_deref(), Some("true"));
1134    }
1135
1136    #[test]
1137    fn go_cgo_knob_switches_compile_env() {
1138        // cgo=false (default) ⇒ CGO_ENABLED=0, Go puro; cgo=true ⇒ CGO_ENABLED=1 + CC zig + estático.
1139        let d = tempfile::tempdir().unwrap();
1140        touch(d.path(), "go.mod");
1141        std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap();
1142        let off = resolve_phases(&recipe(&[]), d.path()).unwrap().compile.unwrap();
1143        assert!(off.contains("CGO_ENABLED=0"), "{off}");
1144        let mut r = recipe(&[]);
1145        r.build.cgo = true;
1146        let on = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
1147        assert!(on.contains("CGO_ENABLED=1") && on.contains("zig cc") && on.contains("-extldflags=-static"), "{on}");
1148    }
1149
1150    #[test]
1151    fn detect_go_when_only_go_work() {
1152        // workspace multimódulo sin go.mod en la raíz (sólo go.work) ⇒ sigue siendo Go (antes caía a
1153        // Makefile/Unknown). El vendoring usará `go work vendor`.
1154        let d = tempfile::tempdir().unwrap();
1155        touch(d.path(), "go.work");
1156        touch(d.path(), "Makefile"); // un Makefile auxiliar no debe ganarle a Go
1157        assert_eq!(detect_build_system(d.path()), BuildSys::Go);
1158    }
1159
1160    #[test]
1161    fn detect_go_main_picks_cmd_over_docs() {
1162        // main real en cmd/mlr + main de ejemplo roto en docs/ ⇒ elige cmd/mlr, ignora docs.
1163        let d = tempfile::tempdir().unwrap();
1164        for sub in ["cmd/mlr", "docs/example"] {
1165            std::fs::create_dir_all(d.path().join(sub)).unwrap();
1166            std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap();
1167        }
1168        assert_eq!(detect_go_main(d.path(), "mlr"), "./cmd/mlr");
1169    }
1170
1171    #[test]
1172    fn detect_go_main_prefers_cmd_matching_recipe_name() {
1173        // eksctl-like: cmd/eksctl (CLI real) + cmd/schema (helper que panica). El dir que coincide
1174        // con el name de la receta gana sobre el hermano ⇒ no sella el binario equivocado.
1175        let d = tempfile::tempdir().unwrap();
1176        for sub in ["cmd/schema", "cmd/eksctl"] {
1177            std::fs::create_dir_all(d.path().join(sub)).unwrap();
1178            std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap();
1179        }
1180        assert_eq!(detect_go_main(d.path(), "eksctl"), "./cmd/eksctl");
1181    }
1182
1183    #[test]
1184    fn detect_go_main_sees_main_under_block_comment_header() {
1185        // header de licencia `/* ... */` con líneas internas SIN `*` (estilo Apache de helm):
1186        // el main en cmd/helm debe detectarse igual (regresión del corte ingenuo por-línea).
1187        let d = tempfile::tempdir().unwrap();
1188        std::fs::create_dir_all(d.path().join("cmd/helm")).unwrap();
1189        std::fs::write(
1190            d.path().join("cmd/helm/helm.go"),
1191            b"/*\nCopyright The Helm Authors.\nLicensed under the Apache License 2.0\n*/\n\npackage main\n\nfunc main() {}\n",
1192        )
1193        .unwrap();
1194        assert_eq!(detect_go_main(d.path(), "helm"), "./cmd/helm");
1195    }
1196
1197    #[test]
1198    fn detect_autoconf_raw_when_only_configure_ac() {
1199        let d = tempfile::tempdir().unwrap();
1200        touch(d.path(), "configure.ac");
1201        assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw);
1202        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1203        assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&"));
1204        assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out"));
1205    }
1206
1207    #[test]
1208    fn detect_autoconf_ready_when_configure_exists() {
1209        let d = tempfile::tempdir().unwrap();
1210        touch(d.path(), "configure.ac");
1211        touch(d.path(), "configure");
1212        assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady);
1213        let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap();
1214        let c = p.configure.unwrap();
1215        assert!(c.starts_with("./configure --prefix=/usr"), "{c}");
1216        assert!(c.ends_with("--enable-foo"), "{c}");
1217    }
1218
1219    #[test]
1220    fn autotools_injects_native_triple() {
1221        // Etapa G Fase 3: configure heurístico fija --build/--host al triple saneado.
1222        let d = tempfile::tempdir().unwrap();
1223        touch(d.path(), "configure.ac");
1224        touch(d.path(), "configure");
1225        let c = resolve_phases(&recipe(&[]), d.path()).unwrap().configure.unwrap();
1226        assert!(c.contains("--build=x86_64-linux-musl"), "{c}");
1227        assert!(c.contains("--host=x86_64-linux-musl"), "{c}");
1228    }
1229
1230    #[test]
1231    fn autotools_triple_yields_to_recipe_flags() {
1232        // Si la receta ya fija el triple (juicio per-paquete), no duplicamos.
1233        let d = tempfile::tempdir().unwrap();
1234        touch(d.path(), "configure.ac");
1235        touch(d.path(), "configure");
1236        let c = resolve_phases(&recipe(&["--host=aarch64-linux-musl"]), d.path())
1237            .unwrap()
1238            .configure
1239            .unwrap();
1240        assert!(c.contains("--host=aarch64-linux-musl"), "{c}");
1241        assert!(!c.contains("x86_64-linux-musl"), "no inyecta si la receta ya lo puso ({c})");
1242    }
1243
1244    #[test]
1245    fn cmake_ignores_autotools_triple() {
1246        // El triple es exclusivo de autotools; CMake/Meson/Cargo no lo reciben.
1247        let d = tempfile::tempdir().unwrap();
1248        touch(d.path(), "CMakeLists.txt");
1249        let c = resolve_phases(&recipe(&[]), d.path()).unwrap().configure.unwrap();
1250        assert!(!c.contains("--host="), "{c}");
1251    }
1252
1253    #[test]
1254    fn detect_cmake() {
1255        let d = tempfile::tempdir().unwrap();
1256        touch(d.path(), "CMakeLists.txt");
1257        assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
1258        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1259        assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr"));
1260        assert!(p.compile.unwrap().contains("cmake --build _build"));
1261        assert!(p.install.unwrap().contains("DESTDIR=/out"));
1262    }
1263
1264    #[test]
1265    fn detect_meson() {
1266        let d = tempfile::tempdir().unwrap();
1267        touch(d.path(), "meson.build");
1268        assert_eq!(detect_build_system(d.path()), BuildSys::Meson);
1269        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1270        assert!(p.configure.unwrap().contains("meson setup _build"));
1271        assert!(p.install.unwrap().contains("meson install"));
1272    }
1273
1274    #[test]
1275    fn detect_cargo() {
1276        let d = tempfile::tempdir().unwrap();
1277        touch(d.path(), "Cargo.toml");
1278        assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
1279        // Target default (x86_64-linux-musl) = nativo del sandbox ⇒ build SIN --target.
1280        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1281        assert!(p.configure.is_none(), "cargo no usa configure");
1282        let c = p.compile.unwrap();
1283        // Nativo: `cargo rustc` (no `cargo build`) para pasar los flags de codegen tras `--` sólo
1284        // a la crate final; sin `--target` (lo cross sí; el único --target es el del wrapper saneador).
1285        assert!(c.contains("cargo rustc --release"), "{c}");
1286        assert!(c.contains("--locked") && c.contains("--offline"), "{c}");
1287        assert!(!c.contains("--offline --target"), "nativo: cargo sin --target ({c})");
1288        assert!(c.contains("a=--target=x86_64-linux-musl"), "wrapper sanea el triple ({c})");
1289        assert!(c.contains("-- -C target-feature=+crt-static"), "crt-static scoped tras -- ({c})");
1290        assert!(c.contains("relocation-model=static"), "no-PIE: ET_EXEC sin interpreter ({c})");
1291        assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC = wrapper para cc-rs ({c})");
1292        // El linker se arma en `RF` (que además gana `-C link-self-contained=no` cuando el host no es
1293        // Alpine) y se aplica vía `RUSTFLAGS="$RF"`.
1294        assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker en RF ({c})");
1295        assert!(c.contains("RUSTFLAGS=\"$RF\""), "RF aplicado por RUSTFLAGS ({c})");
1296        let i = p.install.unwrap();
1297        assert!(i.contains("/out/usr/bin"), "{i}");
1298        assert!(i.contains("find target/release"), "nativo instala de target/release ({i})");
1299    }
1300
1301    #[test]
1302    fn cargo_gcc_ghetto_uses_gcc_for_build_scripts() {
1303        // Receta Rust con `compiler="gcc"`: el GUETO para crates con dep-C (libgit2-sys/openssl-sys/
1304        // libc-stdhandle) que zig-cc rechaza. cc-rs debe usar gcc (CC=gcc), pero el LINKER sigue
1305        // siendo el wrapper zig-cc (objetos gcc-musl + Rust zig-musl son ABI-compatibles).
1306        let d = tempfile::tempdir().unwrap();
1307        touch(d.path(), "Cargo.toml");
1308        let mut r = recipe(&[]);
1309        r.build.compiler = Compiler::Gcc;
1310        let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
1311        assert!(c.contains("CC=\"gcc\""), "cc-rs usa gcc en el gueto ({c})");
1312        assert!(!c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC ya no es el wrapper ({c})");
1313        // El linker NO cambia: sigue el wrapper zig-cc.
1314        assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker sigue zig-cc ({c})");
1315        assert!(c.contains("cargo rustc --release"), "sigue el camino cargo nativo ({c})");
1316    }
1317
1318    #[test]
1319    fn cargo_default_compiler_keeps_zig_cc_for_build_scripts() {
1320        // Sin `compiler=` (default ZigCc): CC sigue siendo el wrapper — baseline ripgrep/fd intacto.
1321        let d = tempfile::tempdir().unwrap();
1322        touch(d.path(), "Cargo.toml");
1323        let c = resolve_phases(&recipe(&[]), d.path()).unwrap().compile.unwrap();
1324        assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "default: cc-rs usa el wrapper zig ({c})");
1325    }
1326
1327    #[test]
1328    fn cargo_cross_target_keeps_triple() {
1329        // Un target NO nativo (cross real) sí pasa --target + el linker per-triple.
1330        let d = tempfile::tempdir().unwrap();
1331        touch(d.path(), "Cargo.toml");
1332        let mut r = recipe(&[]);
1333        r.build.target = "aarch64-linux-musl".into();
1334        let p = resolve_phases(&r, d.path()).unwrap();
1335        let c = p.compile.unwrap();
1336        assert!(c.contains("--target aarch64-unknown-linux-musl"), "{c}");
1337        assert!(c.contains("CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="), "{c}");
1338        assert!(p.install.unwrap().contains("target/aarch64-unknown-linux-musl/release"));
1339    }
1340
1341    #[test]
1342    fn cargo_wins_over_stray_makefile_but_not_cmake() {
1343        let d = tempfile::tempdir().unwrap();
1344        touch(d.path(), "Cargo.toml");
1345        touch(d.path(), "Makefile");
1346        assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
1347        // pero un proyecto C+Rust con CMakeLists prefiere el sistema C
1348        touch(d.path(), "CMakeLists.txt");
1349        assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
1350    }
1351
1352    #[test]
1353    fn cargo_flags_go_to_compile() {
1354        let d = tempfile::tempdir().unwrap();
1355        touch(d.path(), "Cargo.toml");
1356        let p = resolve_phases(&recipe(&["--features", "gpu"]), d.path()).unwrap();
1357        // Los flags de la receta van como args de cargo (antes del `--` que separa los de rustc).
1358        assert!(p.compile.unwrap().contains("--features gpu -- "));
1359    }
1360
1361    #[test]
1362    fn cargo_dynamic_disables_crt_static() {
1363        let d = tempfile::tempdir().unwrap();
1364        touch(d.path(), "Cargo.toml");
1365        let mut r = recipe(&[]);
1366        r.build.link = takana_core::LinkMode::Dynamic;
1367        let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
1368        assert!(c.contains("target-feature=-crt-static"), "{c}");
1369    }
1370
1371    #[test]
1372    fn rustc_triple_translation() {
1373        assert_eq!(rustc_triple("x86_64-linux-musl"), "x86_64-unknown-linux-musl");
1374        assert_eq!(rustc_triple("aarch64-linux-gnu"), "aarch64-unknown-linux-gnu");
1375        // un triple ya completo pasa intacto
1376        assert_eq!(rustc_triple("x86_64-unknown-linux-musl"), "x86_64-unknown-linux-musl");
1377        assert_eq!(
1378            cargo_linker_env_var("x86_64-unknown-linux-musl"),
1379            "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER"
1380        );
1381    }
1382
1383    #[test]
1384    fn detect_make() {
1385        let d = tempfile::tempdir().unwrap();
1386        touch(d.path(), "Makefile");
1387        assert_eq!(detect_build_system(d.path()), BuildSys::Make);
1388        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1389        assert!(p.configure.is_none(), "make plano no necesita configure");
1390        assert!(p.compile.unwrap().starts_with("make"));
1391    }
1392
1393    #[test]
1394    fn unknown_returns_no_phases() {
1395        let d = tempfile::tempdir().unwrap();
1396        // sólo un README, ningún archivo característico.
1397        std::fs::write(d.path().join("README"), b"hi").unwrap();
1398        assert_eq!(detect_build_system(d.path()), BuildSys::Unknown);
1399        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1400        assert!(p.compile.is_none());
1401        assert!(p.install.is_none());
1402    }
1403
1404    #[test]
1405    fn recipe_override_wins_over_heuristic() {
1406        let d = tempfile::tempdir().unwrap();
1407        touch(d.path(), "Makefile");
1408        let mut r = recipe(&[]);
1409        r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string());
1410        r.build.phases.install = Some("cp foo /out/bin/foo".to_string());
1411        let p = resolve_phases(&r, d.path()).unwrap();
1412        assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c");
1413        assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo");
1414    }
1415}
1416
1417
1418fn unique_out_dir(
1419    work_root: &Path,
1420    name: &str,
1421    h: &ArtifactHash,
1422) -> takana_core::Result<PathBuf> {
1423    let dir = work_root
1424        .join("out")
1425        .join(h.store_dir_name(name));
1426    if dir.exists() {
1427        std::fs::remove_dir_all(&dir)?;
1428    }
1429    std::fs::create_dir_all(&dir)?;
1430    Ok(dir)
1431}
1432
1433/// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del
1434/// artefacto a partir del hash y delega en [`hydrate::hydrate`].
1435pub fn hydrate(
1436    h: &ArtifactHash,
1437    store: &Store,
1438    target_fhs: &Path,
1439    mode: LinkMode,
1440    dynamic: Option<&hydrate::DynamicSpec>,
1441) -> takana_core::Result<HydrateReport> {
1442    let artifact_dir = store.find_by_hash(h.as_str())?;
1443    hydrate::hydrate(&artifact_dir, target_fhs, mode, dynamic)
1444}
1445
1446#[cfg(test)]
1447mod dep_hash_tests {
1448    use super::*;
1449
1450    /// Escribe `<dir>/<name>.toml` con `source.commit = commit` y, si hay, `deps.build = deps`.
1451    fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) {
1452        let deps_block = if deps.is_empty() {
1453            String::new()
1454        } else {
1455            let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
1456            format!("\n[deps]\nbuild = [{list}]\n")
1457        };
1458        let toml = format!(
1459            "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}"
1460        );
1461        std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap();
1462    }
1463
1464    fn load(dir: &Path, name: &str) -> Recipe {
1465        Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap()
1466    }
1467
1468    fn store_in(dir: &Path) -> Store {
1469        Store::open(dir.join("store")).unwrap()
1470    }
1471
1472    #[test]
1473    fn dep_contributes_to_hash() {
1474        let d = tempfile::tempdir().unwrap();
1475        let store = store_in(d.path());
1476        write_recipe(d.path(), "base", "b0", &[]);
1477        write_recipe(d.path(), "app", "a0", &["base"]);
1478        write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep
1479
1480        let with_dep = artifact_hash(&load(d.path(), "app"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap();
1481        let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap();
1482        // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia
1483        // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.)
1484        assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash");
1485    }
1486
1487    #[test]
1488    fn transitive_dep_change_propagates() {
1489        let d = tempfile::tempdir().unwrap();
1490        let store = store_in(d.path());
1491        write_recipe(d.path(), "base", "b0", &[]);
1492        write_recipe(d.path(), "lib", "l0", &["base"]);
1493        write_recipe(d.path(), "app", "a0", &["lib"]);
1494        let before = artifact_hash(&load(d.path(), "app"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap();
1495
1496        // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`.
1497        write_recipe(d.path(), "base", "b1", &[]);
1498        let after = artifact_hash(&load(d.path(), "app"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap();
1499        assert_ne!(before, after, "un cambio transitivo debe propagarse");
1500    }
1501
1502    #[test]
1503    fn missing_dep_toml_errors_with_names() {
1504        let d = tempfile::tempdir().unwrap();
1505        let store = store_in(d.path());
1506        write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml
1507        let err = artifact_hash(&load(d.path(), "app"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string();
1508        assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}");
1509        assert!(err.contains("app"), "y la receta que la pide: {err}");
1510    }
1511
1512    #[test]
1513    fn cycle_is_detected() {
1514        let d = tempfile::tempdir().unwrap();
1515        let store = store_in(d.path());
1516        write_recipe(d.path(), "a", "a0", &["b"]);
1517        write_recipe(d.path(), "b", "b0", &["a"]);
1518        let err = artifact_hash(&load(d.path(), "a"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string();
1519        assert!(err.contains("ciclo"), "esperaba ciclo: {err}");
1520        assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}");
1521    }
1522
1523    #[test]
1524    fn deps_without_base_dir_errors() {
1525        let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap();
1526        // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver.
1527        let r = Recipe::from_toml(
1528            "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n",
1529        )
1530        .unwrap();
1531        let err = artifact_hash(&r, &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string();
1532        assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}");
1533    }
1534
1535    #[test]
1536    fn no_deps_unaffected() {
1537        let d = tempfile::tempdir().unwrap();
1538        let store = store_in(d.path());
1539        write_recipe(d.path(), "solo", "s0", &[]);
1540        let a = artifact_hash(&load(d.path(), "solo"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap();
1541        let b = artifact_hash(&load(d.path(), "solo"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap();
1542        assert_eq!(a, b, "sin deps el hash es estable");
1543    }
1544}