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 · 2026-06-07T19:37:14Z (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
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");
+}