gioser

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 1389Shell 137Markdown 80Rust 802602 Definiciones

Heurística de build systems: autotools, cmake, meson, make

detect_build_system inspecciona el árbol del source y elige los comandos:
- configure.ac sin configure → autoreconf -fi && ./configure --prefix=/usr
- configure presente         → ./configure --prefix=/usr
- CMakeLists.txt             → cmake -B _build ... ; cmake --build ; cmake --install
- meson.build                → meson setup _build ... ; meson compile ; meson install
- Makefile / GNUmakefile     → make -j && make install DESTDIR=/out PREFIX=/usr
- nada                       → la receta debe traer [build.phases]

Overrides en la receta siguen ganando. recipe.build.flags se concatenan al
configure. Cuando link = "static", el orquestador inyecta LDFLAGS=-static
para que el link final autotools/cmake produzca el binario monolítico que
quiere nuestra hidratación primaria.

Tests:
- 7 unit tests de detección + emisión de comandos por sistema.
- 1 integration test end-to-end con autotools real (configure.ac + Makefile.am
  + hello.c → autoreconf+configure+make+install dentro del sandbox, sellado,
  ejecutado bajo bwrap). Requiere el rootfs Alpine con `apk add make
  autoconf automake m4 patch coreutils libtool pkgconf bash`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

736e73e2ef95425789bcbbd588d394ac49c59987 · por Sergio · (hace 4 meses) · padre 7b362e9d6 · ver el árbol en este commit

Delta semántico · 23

crates/hammer-build/src/lib.rs

crates/hammer-build/tests/end_to_end_autotools.rs

2 archivos · +350 −9

diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs
index 6e9ae901..3c1de99b 100644
--- a/crates/hammer-build/src/lib.rs
+++ b/crates/hammer-build/src/lib.rs
@@ -49,12 +49,18 @@ pub fn build(
     }
 
     let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
+    let mut env: Vec<(String, String)> = Vec::new();
+    if matches!(recipe.build.link, LinkMode::Static) {
+        // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario
+        // monolítico que pide nuestra estrategia de hidratación primaria.
+        env.push(("LDFLAGS".into(), "-static".into()));
+    }
     let sb = Sandbox {
         rootfs: cfg.rootfs.clone(),
         zig_dir: cfg.zig_dir.clone(),
         src_dir: src_tree.clone(),
         out_dir: out_dir.clone(),
-        env: Vec::new(),
+        env,
     };
 
     let phases = resolve_phases(recipe, &src_tree)?;
@@ -84,22 +90,196 @@ pub fn build(
     Ok(h)
 }
 
-/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos > heurística
-/// (de momento: presencia de `Makefile` → make).
+/// Sistemas de build que reconoce la heurística por inspección del árbol del source.
+/// Ver `docs/02-build-lab.md` §4.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum BuildSys {
+    /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero.
+    AutoconfRaw,
+    /// `configure` ya está en el tarball release: vamos directo a `./configure`.
+    AutoconfReady,
+    /// `CMakeLists.txt`.
+    CMake,
+    /// `meson.build`.
+    Meson,
+    /// `Makefile` plano (o `makefile`) sin sistema generador.
+    Make,
+    /// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
+    Unknown,
+}
+
+fn detect_build_system(src: &Path) -> BuildSys {
+    let has = |p: &str| src.join(p).exists();
+    // El orden importa: cuando coexisten varios (un tarball release de autotools trae el
+    // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el
+    // sistema "más alto" que sí sabe regenerar todo.
+    if has("configure.ac") || has("configure.in") {
+        if has("configure") {
+            return BuildSys::AutoconfReady;
+        }
+        return BuildSys::AutoconfRaw;
+    }
+    if has("configure") {
+        return BuildSys::AutoconfReady;
+    }
+    if has("CMakeLists.txt") {
+        return BuildSys::CMake;
+    }
+    if has("meson.build") {
+        return BuildSys::Meson;
+    }
+    if has("Makefile") || has("makefile") || has("GNUmakefile") {
+        return BuildSys::Make;
+    }
+    BuildSys::Unknown
+}
+
+/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta
+/// ganan; el resto lo deriva la heurística según el sistema de build detectado.
 fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
     let mut out = recipe.build.phases.clone();
+    let bs = detect_build_system(src);
+    let flags = recipe.build.flags.join(" ");
+    let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") };
+
+    if out.configure.is_none() {
+        out.configure = match bs {
+            BuildSys::AutoconfRaw => Some(format!(
+                "autoreconf -fi && ./configure --prefix=/usr{flags_suffix}"
+            )),
+            BuildSys::AutoconfReady => Some(format!("./configure --prefix=/usr{flags_suffix}")),
+            BuildSys::CMake => Some(format!(
+                "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}"
+            )),
+            BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")),
+            BuildSys::Make | BuildSys::Unknown => None,
+        };
+    }
     if out.compile.is_none() {
-        if src.join("Makefile").exists() || src.join("makefile").exists() {
-            out.compile = Some("make".to_string());
-            if out.install.is_none() {
-                out.install = Some("make install DESTDIR=/out PREFIX=/usr".to_string());
+        out.compile = match bs {
+            BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
+                Some(r#"make -j"$(nproc)""#.to_string())
             }
-        }
-        // TODO(fase-0): configure.ac → autoreconf+./configure; CMakeLists.txt → cmake; meson.build → meson.
+            BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()),
+            BuildSys::Meson => Some("meson compile -C _build".to_string()),
+            BuildSys::Unknown => None,
+        };
+    }
+    if out.install.is_none() {
+        out.install = match bs {
+            BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
+                Some("make install DESTDIR=/out PREFIX=/usr".to_string())
+            }
+            BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()),
+            BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()),
+            BuildSys::Unknown => None,
+        };
     }
     Ok(out)
 }
 
+#[cfg(test)]
+mod heuristic_tests {
+    use super::*;
+
+    fn touch(dir: &Path, name: &str) {
+        std::fs::write(dir.join(name), b"").unwrap();
+    }
+
+    fn recipe(flags: &[&str]) -> Recipe {
+        let mut r = Recipe::from_toml(
+            r#"
+name = "x"
+version = "0"
+[source]
+repo = "git://x"
+commit = "deadbeef"
+[build]
+"#,
+        )
+        .unwrap();
+        r.build.flags = flags.iter().map(|s| (*s).to_string()).collect();
+        r
+    }
+
+    #[test]
+    fn detect_autoconf_raw_when_only_configure_ac() {
+        let d = tempfile::tempdir().unwrap();
+        touch(d.path(), "configure.ac");
+        assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw);
+        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
+        assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&"));
+        assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out"));
+    }
+
+    #[test]
+    fn detect_autoconf_ready_when_configure_exists() {
+        let d = tempfile::tempdir().unwrap();
+        touch(d.path(), "configure.ac");
+        touch(d.path(), "configure");
+        assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady);
+        let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap();
+        let c = p.configure.unwrap();
+        assert!(c.starts_with("./configure --prefix=/usr"), "{c}");
+        assert!(c.ends_with("--enable-foo"), "{c}");
+    }
+
+    #[test]
+    fn detect_cmake() {
+        let d = tempfile::tempdir().unwrap();
+        touch(d.path(), "CMakeLists.txt");
+        assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
+        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
+        assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr"));
+        assert!(p.compile.unwrap().contains("cmake --build _build"));
+        assert!(p.install.unwrap().contains("DESTDIR=/out"));
+    }
+
+    #[test]
+    fn detect_meson() {
+        let d = tempfile::tempdir().unwrap();
+        touch(d.path(), "meson.build");
+        assert_eq!(detect_build_system(d.path()), BuildSys::Meson);
+        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
+        assert!(p.configure.unwrap().contains("meson setup _build"));
+        assert!(p.install.unwrap().contains("meson install"));
+    }
+
+    #[test]
+    fn detect_make() {
+        let d = tempfile::tempdir().unwrap();
+        touch(d.path(), "Makefile");
+        assert_eq!(detect_build_system(d.path()), BuildSys::Make);
+        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
+        assert!(p.configure.is_none(), "make plano no necesita configure");
+        assert!(p.compile.unwrap().starts_with("make"));
+    }
+
+    #[test]
+    fn unknown_returns_no_phases() {
+        let d = tempfile::tempdir().unwrap();
+        // sólo un README, ningún archivo característico.
+        std::fs::write(d.path().join("README"), b"hi").unwrap();
+        assert_eq!(detect_build_system(d.path()), BuildSys::Unknown);
+        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
+        assert!(p.compile.is_none());
+        assert!(p.install.is_none());
+    }
+
+    #[test]
+    fn recipe_override_wins_over_heuristic() {
+        let d = tempfile::tempdir().unwrap();
+        touch(d.path(), "Makefile");
+        let mut r = recipe(&[]);
+        r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string());
+        r.build.phases.install = Some("cp foo /out/bin/foo".to_string());
+        let p = resolve_phases(&r, d.path()).unwrap();
+        assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c");
+        assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo");
+    }
+}
+
+
 fn unique_out_dir(
     work_root: &Path,
     name: &str,
diff --git a/crates/hammer-build/tests/end_to_end_autotools.rs b/crates/hammer-build/tests/end_to_end_autotools.rs
new file mode 100644
index 00000000..1380b14a
--- /dev/null
+++ b/crates/hammer-build/tests/end_to_end_autotools.rs
@@ -0,0 +1,161 @@
+//! Integration test del path autotools:
+//!   - Repo git con `configure.ac` + `Makefile.am` + `hello.c` (sin `configure` generado).
+//!   - Receta SIN `[build.phases]` → la heurística debe detectar AutoconfRaw y emitir
+//!     `autoreconf -fi && ./configure --prefix=/usr` + `make` + `make install DESTDIR=/out`.
+//!   - El binario instalado en el store debe correr en el sandbox.
+//!
+//! Skip transparente si el rootfs Alpine o zig no están bootstrappeados.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use hammer_build::{build, BuildConfig};
+use hammer_core::{Recipe, Store};
+
+const HELLO_C: &str = r#"
+#include <stdio.h>
+int main(void) { puts("hammer-autotools OK"); return 0; }
+"#;
+
+const CONFIGURE_AC: &str = r#"
+AC_INIT([helloat], [1.0])
+AM_INIT_AUTOMAKE([foreign -Wall])
+AC_PROG_CC
+AC_CONFIG_FILES([Makefile])
+AC_OUTPUT
+"#;
+
+const MAKEFILE_AM: &str = r#"
+bin_PROGRAMS = helloat
+helloat_SOURCES = hello.c
+"#;
+
+const RECIPE_TOML: &str = r#"
+name    = "helloat"
+version = "1.0"
+
+[source]
+repo   = "PLACEHOLDER_REPO_URL"
+commit = "PLACEHOLDER_COMMIT"
+
+[build]
+compiler = "zig-cc"
+target   = "x86_64-linux-musl"
+link     = "static"
+"#;
+
+fn project_root() -> PathBuf {
+    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+        .parent()
+        .unwrap()
+        .parent()
+        .unwrap()
+        .to_path_buf()
+}
+
+fn skip_if_no_layout(cfg: &BuildConfig) -> bool {
+    if !cfg.rootfs.join("bin/busybox").is_file() {
+        eprintln!("SKIP: rootfs Alpine no encontrado en {}.", cfg.rootfs.display());
+        return true;
+    }
+    // Las tools de autotools se metieron con `apk add make autoconf automake m4 ...`
+    // — comprobamos uno para no engañarnos si el rootfs es el minirootfs pelado.
+    if !cfg.rootfs.join("usr/bin/autoreconf").is_file() {
+        eprintln!(
+            "SKIP: rootfs sin autotools. Corre: bwrap --bind {} / --share-net /sbin/apk add make autoconf automake m4 patch coreutils libtool pkgconf bash",
+            cfg.rootfs.display()
+        );
+        return true;
+    }
+    if !cfg.zig_dir.join("zig").is_file() {
+        eprintln!("SKIP: zig no encontrado en {}.", cfg.zig_dir.display());
+        return true;
+    }
+    false
+}
+
+fn git(args: &[&str], cwd: &Path) {
+    let st = Command::new("git").args(args).current_dir(cwd).status().expect("git");
+    assert!(st.success(), "git {args:?} falló");
+}
+
+fn init_repo(upstream: &Path) -> String {
+    std::fs::create_dir_all(upstream).unwrap();
+    std::fs::write(upstream.join("configure.ac"), CONFIGURE_AC).unwrap();
+    std::fs::write(upstream.join("Makefile.am"), MAKEFILE_AM).unwrap();
+    std::fs::write(upstream.join("hello.c"), HELLO_C).unwrap();
+    git(&["init", "-q", "-b", "main"], upstream);
+    git(&["config", "user.email", "test@hammer"], upstream);
+    git(&["config", "user.name", "hammer-test"], upstream);
+    git(&["add", "."], upstream);
+    git(&["commit", "-q", "-m", "init"], upstream);
+    let out = Command::new("git")
+        .args(["rev-parse", "HEAD"])
+        .current_dir(upstream)
+        .output()
+        .unwrap();
+    String::from_utf8(out.stdout).unwrap().trim().to_string()
+}
+
+#[test]
+fn build_autotools_hello_end_to_end() {
+    let _ = tracing_subscriber::fmt().with_test_writer().try_init();
+
+    let project = project_root();
+    let cfg = BuildConfig {
+        rootfs: project.join(".dev-fs/alpine"),
+        zig_dir: project.join(".dev-fs/tools/zig"),
+        work_root: tempfile::tempdir().unwrap().keep(),
+    };
+    if skip_if_no_layout(&cfg) {
+        return;
+    }
+
+    let tmp = tempfile::tempdir().unwrap();
+    let store_root = tmp.path().join("store");
+    std::fs::create_dir_all(&store_root).unwrap();
+
+    let upstream = tmp.path().join("upstream");
+    let commit = init_repo(&upstream);
+    let repo_url = format!("file://{}", upstream.display());
+
+    let recipe_dir = tmp.path().join("recipe");
+    std::fs::create_dir_all(&recipe_dir).unwrap();
+    let toml = RECIPE_TOML
+        .replace("PLACEHOLDER_REPO_URL", &repo_url)
+        .replace("PLACEHOLDER_COMMIT", &commit);
+    let recipe_path = recipe_dir.join("helloat.toml");
+    std::fs::write(&recipe_path, toml).unwrap();
+    let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
+
+    let store = Store::open(&store_root).unwrap();
+    let h = build(&recipe, &cfg, &store).expect("build helloat");
+    let sealed = store.path_of(&h, &recipe.name);
+
+    let bin = sealed.join("usr/bin/helloat");
+    assert!(bin.is_file(), "esperaba {}", bin.display());
+
+    let header = std::fs::read(&bin).unwrap();
+    assert_eq!(&header[..4], b"\x7fELF", "es ELF");
+
+    let out = Command::new("bwrap")
+        .args([
+            "--overlay-src",
+            cfg.rootfs.to_str().unwrap(),
+            "--tmp-overlay",
+            "/",
+            "--proc",
+            "/proc",
+            "--dev",
+            "/dev",
+            "--ro-bind",
+            sealed.to_str().unwrap(),
+            "/art",
+            "--unshare-all",
+            "/art/usr/bin/helloat",
+        ])
+        .output()
+        .expect("spawn bwrap");
+    assert!(out.status.success(), "helloat falló: {:?}", out);
+    assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hammer-autotools OK");
+}