From 5877363f5b4369648b09a884904c45c252a045bd Mon Sep 17 00:00:00 2001 From: sergio Date: Fri, 26 Jun 2026 12:34:27 -0400 Subject: [PATCH] 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/ > 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 --- crates/hammer-build/src/lib.rs | 128 +++++++++++++++++++++++++++- crates/hammer-cli/src/nix_import.rs | 20 ++++- scripts/nix-import.sh | 2 + 3 files changed, 146 insertions(+), 4 deletions(-) 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/` > +/// 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 = 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/ (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 { "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 { )) } } + 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→) 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 { -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 { // ripgrep-no-jemalloc), no una dep de corpus. Las recetas Rust validadas (ripgrep/uutils) NO // declaran `[deps]`; emitirlas rompe `hammer build` (busca recipes/.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 { // 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 ""; }')