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>
This commit is contained in:
2026-06-26 12:34:27 -04:00
co-authored by Claude Opus 4.8
parent 8237635256
commit 5877363f5b
3 changed files with 146 additions and 4 deletions
+126 -2
View File
@@ -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();
+18 -2
View File
@@ -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()
+2
View File
@@ -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 "";
}')