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>
This commit is contained in:
Sergio
2026-06-07 19:37:14 +00:00
co-authored by Claude Opus 4.7
parent 7b362e9d6a
commit 736e73e2ef
2 changed files with 350 additions and 9 deletions
+189 -9
View File
@@ -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,
@@ -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");
}