> 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…
Etapa G frente Go: AUTOMATIZADO end-to-end (BuildSys::Go + importador)
Cierra la fragilidad manual del patron Go (path del main por receta). Ahora
importar Go es tan automatico como Rust: import -> pin -> build, sin tocar nada.
- lib.rs: BuildSys::Go (go.mod, prioritario sobre configure/make auxiliares que
traen muchos proyectos Go). resolve_phases deriva el compile generico:
'go install -trimpath -ldflags=-buildid=' del paquete main. install='true'
(go install ya deja en GOBIN=/out/usr/bin).
- detect_go_main(): detecta el dir del main por FILESYSTEM (no compila, evita
contaminarse con mains de ejemplo rotos en docs/scripts que rompen go list).
Heuristica raiz > cmd/<x> > menor profundidad; excluye vendor/docs/test/etc.
go install nombra el binario solo (cmd/mlr->mlr, raiz->modulo).
- nix_import.rs + nix-import.sh: detecta is_go (vendorHash de buildGoModule),
emite deps.build=['go'] sin phases (BuildSys::Go las deriva).
- tests: go_mod_wins_over_configure, detect_go_main_picks_cmd_over_docs.
Validado end-to-end: miller (cmd/mlr->mlr, esquiva docs rotos), amfora (raiz),
duf (import->pin->build 100% automatico, binario estatico que corre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs
index 002c41ef..011155e3 100644
--- a/crates/hammer-build/src/lib.rs
+++ b/crates/hammer-build/src/lib.rs
@@ -330,6 +330,9 @@ enum BuildSys {
Meson,
/// `Cargo.toml`: crate o workspace Rust. Build con `cargo`, link con `zig cc`.
Cargo,
+ /// `go.mod`: módulo Go. Build con `go install` del paquete main (auto-detectado),
+ /// deps vendoreadas en el fetch (`vendor_go_deps`). El toolchain `go` llega por deps.build.
+ Go,
/// `Makefile` plano (o `makefile`) sin sistema generador.
Make,
/// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
@@ -440,6 +443,12 @@ fn ensure_cargo_workspace_isolation(src: &Path) -> hammer_core::Result<()> {
fn detect_build_system(src: &Path) -> BuildSys {
let has = |p: &str| src.join(p).exists();
+ // `go.mod` PRIMERO: un módulo Go suele traer también un `configure`/`Makefile` AUXILIAR (para
+ // man pages, completions, packaging), pero el binario se hace con `go build`. Ningún paquete
+ // C/Rust del corpus trae go.mod, así que esto sólo captura proyectos Go (miller, etc.).
+ if has("go.mod") {
+ return BuildSys::Go;
+ }
// 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.
@@ -469,6 +478,75 @@ fn detect_build_system(src: &Path) -> BuildSys {
BuildSys::Unknown
}
+/// Detecta el directorio del paquete `main` PRINCIPAL de un módulo Go, relativo a `src`, para
+/// `go install`. Busca por FILESYSTEM (no compila ⇒ no se contamina con mains de ejemplo rotos,
+/// como los de `docs/`/`scripts/` que rompen `go list ./...`): archivos `.go` cuya primera línea
+/// significativa es `package main`, descartando dirs auxiliares (vendor/docs/examples/tests/
+/// scripts/tools/internal y ocultos/`_`). Elige el principal por heurística: raíz > `cmd/<x>` >
+/// menor profundidad. Devuelve un path estilo go (`.`, `./cmd/mlr`). Una receta puede forzar el
+/// paquete vía `build.flags` (gana sobre la auto-detección).
+fn detect_go_main(src: &Path) -> String {
+ fn is_main_go(path: &Path) -> bool {
+ let Ok(text) = std::fs::read_to_string(path) else { return false };
+ for line in text.lines() {
+ let l = line.trim();
+ if l.is_empty() || l.starts_with("//") || l.starts_with("/*") || l.starts_with('*') {
+ continue;
+ }
+ return l.starts_with("package main");
+ }
+ false
+ }
+ const SKIP: &[&str] = &[
+ "vendor", "docs", "doc", "example", "examples", "test", "tests", "testdata", "script",
+ "scripts", "tools", "internal", "third_party",
+ ];
+ let mut found: Vec<PathBuf> = Vec::new();
+ let mut stack = vec![src.to_path_buf()];
+ while let Some(dir) = stack.pop() {
+ let Ok(entries) = std::fs::read_dir(&dir) else { continue };
+ let mut has_main = false;
+ for e in entries.flatten() {
+ let p = e.path();
+ if p.is_dir() {
+ let name = e.file_name().to_string_lossy().to_string();
+ if name.starts_with('.') || name.starts_with('_') || SKIP.contains(&name.as_str()) {
+ continue;
+ }
+ stack.push(p);
+ } else if !has_main
+ && p.extension().map(|x| x == "go").unwrap_or(false)
+ && !p.to_string_lossy().ends_with("_test.go")
+ && is_main_go(&p)
+ {
+ has_main = true;
+ }
+ }
+ if has_main {
+ if let Ok(rel) = dir.strip_prefix(src) {
+ found.push(rel.to_path_buf());
+ }
+ }
+ }
+ // raíz (prioridad 0) > cmd/<x> (1) > resto (2); a igual clase, menor profundidad.
+ let key = |p: &PathBuf| -> (u8, usize) {
+ let s = p.to_string_lossy();
+ let depth = p.components().count();
+ if s.is_empty() {
+ (0, depth)
+ } else if s.starts_with("cmd/") || s == "cmd" {
+ (1, depth)
+ } else {
+ (2, depth)
+ }
+ };
+ found.sort_by_key(key);
+ match found.first() {
+ Some(p) if !p.as_os_str().is_empty() => format!("./{}", p.to_string_lossy()),
+ _ => ".".to_string(),
+ }
+}
+
/// Target nativo del sandbox del lab (Alpine x86_64 musl). Una receta Cargo cuyo `build.target`
/// sea éste se construye **nativa** (sin `--target`): el rust del sandbox sólo trae su std nativo
/// (`x86_64-alpine-linux-musl`), no el `x86_64-unknown-linux-musl` que pediría un cross, y no hay
@@ -522,8 +600,8 @@ fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
"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}")),
- // Cargo no tiene fase configure separada.
- BuildSys::Cargo | BuildSys::Make | BuildSys::Unknown => None,
+ // Cargo/Go no tienen fase configure separada.
+ BuildSys::Cargo | BuildSys::Go | BuildSys::Make | BuildSys::Unknown => None,
};
}
if out.compile.is_none() {
@@ -621,6 +699,23 @@ fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
))
}
}
+ BuildSys::Go => {
+ // Un solo patrón GENÉRICO para todo proyecto Go: `go install` del paquete main
+ // auto-detectado (o el que fije `build.flags`). go install nombra el binario solo
+ // (cmd/mlr→mlr, raíz→<módulo>) y lo deja en GOBIN=/out/usr/bin ⇒ no hace falta saber
+ // el nombre. Offline+reproducible: vendor (vendor_go_deps) + GOPROXY=off + -trimpath
+ // -buildid=. El toolchain `go` llega por deps.build=["go"] (overlay).
+ let pkg = if recipe.build.flags.is_empty() {
+ detect_go_main(src)
+ } else {
+ flags.clone()
+ };
+ Some(format!(
+ "export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath GOTOOLCHAIN=local \
+ CGO_ENABLED=0 GOFLAGS=-mod=vendor GOPROXY=off GOBIN=/out/usr/bin; \
+ go install -trimpath -ldflags=-buildid= {pkg}"
+ ))
+ }
BuildSys::Unknown => None,
};
}
@@ -646,6 +741,8 @@ fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
-exec cp {{}} /out/usr/bin/ \\;"
))
}
+ // `go install` ya dejó los binarios en GOBIN=/out/usr/bin: nada que hacer.
+ BuildSys::Go => Some("true".to_string()),
BuildSys::Unknown => None,
};
}
@@ -676,6 +773,33 @@ commit = "deadbeef"
r
}
+ #[test]
+ fn go_mod_wins_over_configure_and_derives_go_install() {
+ // miller-like: go.mod + configure auxiliar ⇒ Go gana, y el compile es `go install`.
+ let d = tempfile::tempdir().unwrap();
+ touch(d.path(), "go.mod");
+ touch(d.path(), "configure");
+ std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap();
+ assert_eq!(detect_build_system(d.path()), BuildSys::Go);
+ let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
+ assert!(p.configure.is_none());
+ let c = p.compile.unwrap();
+ assert!(c.contains("go install -trimpath"), "{c}");
+ assert!(c.ends_with(" ."), "raíz ⇒ paquete `.`: {c}");
+ assert_eq!(p.install.as_deref(), Some("true"));
+ }
+
+ #[test]
+ fn detect_go_main_picks_cmd_over_docs() {
+ // main real en cmd/mlr + main de ejemplo roto en docs/ ⇒ elige cmd/mlr, ignora docs.
+ let d = tempfile::tempdir().unwrap();
+ for sub in ["cmd/mlr", "docs/example"] {
+ std::fs::create_dir_all(d.path().join(sub)).unwrap();
+ std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap();
+ }
+ assert_eq!(detect_go_main(d.path()), "./cmd/mlr");
+ }
+
#[test]
fn detect_autoconf_raw_when_only_configure_ac() {
let d = tempfile::tempdir().unwrap();
diff --git a/crates/hammer-cli/src/nix_import.rs b/crates/hammer-cli/src/nix_import.rs
index 4a3cb163..26ed1435 100644
--- a/crates/hammer-cli/src/nix_import.rs
+++ b/crates/hammer-cli/src/nix_import.rs
@@ -32,6 +32,10 @@ pub struct NixPkg {
/// por `cargoDeps`). Activa la plantilla Cargo de hammer (`--bin` + cp target/release).
#[serde(default)]
pub is_rust: bool,
+ /// `true` si nixpkgs lo construye con `buildGoModule` (lo detecta por `vendorHash`/`goModules`).
+ /// Emite `deps.build = ["go"]` y deja las phases a `BuildSys::Go` (go install del main auto).
+ #[serde(default)]
+ pub is_go: bool,
/// El binario principal (`meta.mainProgram` de nix; p.ej. ripgrep→"rg"). Para el `--bin`.
#[serde(default)]
pub main_program: String,
@@ -168,7 +172,16 @@ pub fn to_recipe_toml(pkg: &NixPkg) -> Result<String, String> {
// ripgrep-no-jemalloc), no una dep de corpus. Las recetas Rust validadas (ripgrep/uutils) NO
// declaran `[deps]`; emitirlas rompe `hammer build` (busca recipes/<dep>.toml). Quedan como
// COMENTARIO de provenance (no se pierden: señalan qué C podría necesitarse).
- let deps_block = if deps.is_empty() {
+ let deps_block = if pkg.is_go {
+ // Go: la única build-dep es el toolchain `go` (recipes/go.toml). Los buildInputs C de nix
+ // NO aplican (binario Go estático puro, CGO_ENABLED=0). Quedan como comentario de provenance.
+ let prov = if deps.is_empty() {
+ String::new()
+ } else {
+ format!("# buildInputs de nix (no usados con CGO off): {}\n", deps.join(", "))
+ };
+ format!("\n{prov}[deps]\nbuild = [\"go\"]\n")
+ } else if deps.is_empty() {
String::new()
} else if pkg.is_rust {
let list = deps.join(", ");
@@ -198,7 +211,10 @@ pub fn to_recipe_toml(pkg: &NixPkg) -> Result<String, String> {
// configureFlags → una fase configure explícita (si las hay). Si no, se deja a la heurística
// del lab. NOTA: muchos paquetes nix no son autotools (cmake/meson); esto es best-effort.
- let phases_block = if !rust_install.is_empty() {
+ let phases_block = if pkg.is_go {
+ // Go: sin phases. `BuildSys::Go` deriva el compile (go install del main auto-detectado).
+ String::new()
+ } else if !rust_install.is_empty() {
rust_install
} else if pkg.configure_flags.is_empty() {
String::new()
diff --git a/scripts/nix-import.sh b/scripts/nix-import.sh
index 1a72c3b8..f672c00d 100755
--- a/scripts/nix-import.sh
+++ b/scripts/nix-import.sh
@@ -33,6 +33,8 @@ json=$(nix eval $NIXFLAGS --json "${NIXPKGS}#${ATTR}" --apply '
native_build_inputs = builtins.filter (s: s != "") (map (x: x.pname or x.name or "") (p.nativeBuildInputs or []));
# Rust: buildRustPackage setea cargoDeps ⇒ activa la plantilla Cargo del importador.
is_rust = builtins.hasAttr "cargoDeps" p;
+ # Go: buildGoModule setea vendorHash (goModules en versiones viejas) ⇒ plantilla Go.
+ is_go = builtins.hasAttr "vendorHash" p || builtins.hasAttr "goModules" p;
main_program = p.meta.mainProgram or p.pname or "";
}')