> 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 Fase 1: extractor de workspaces Rust — `-p <pkg>` auto en workspaces virtuales
Sube el yield Rust a casi-todo el ecosistema CLI moderno (sd/fd/… usan workspace virtual: root
sólo agrupa, el bin vive en un sub-paquete ≠ pname). Antes exigía `-p` manual; ahora el lab lo
resuelve solo.
- hammer-build/lib.rs: cargo_root_is_virtual (root con [workspace] sin [package]) +
resolve_cargo_bin_package (lee los members vía `cargo metadata` —fuente autoritativa: globs,
[[bin]], src/bin/*, nested— y devuelve el paquete que expone el bin) + inject_cargo_package_selector
(si virtual y flags piden --bin X sin -p, antepone `-p <pkg>`). Determinista ⇒ reproducible; el
hash usa los flags ORIGINALES, el -p es resolución interna. Crate suelto / ripgrep: intactos
(no virtual). serde_json a deps de hammer-build.
- VALIDADO REAL: sd (workspace virtual, bin en `sd-cli`≠pname) ahora BUILDEA SOLO (sd 1.0.0),
sin tocar la receta (flags quedan --bin sd, el lab resuelve -p sd-cli). 31 suites verde.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock
index 327194cd..5feba38d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -524,6 +524,7 @@ dependencies = [
"anyhow",
"hammer-core",
"hex",
+ "serde_json",
"sha2",
"tempfile",
"thiserror",
diff --git a/crates/hammer-build/Cargo.toml b/crates/hammer-build/Cargo.toml
index 268d3607..d64e2c76 100644
--- a/crates/hammer-build/Cargo.toml
+++ b/crates/hammer-build/Cargo.toml
@@ -14,6 +14,7 @@ thiserror.workspace = true
tracing.workspace = true
sha2.workspace = true
hex.workspace = true
+serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs
index d2f3d02c..3682913c 100644
--- a/crates/hammer-build/src/lib.rs
+++ b/crates/hammer-build/src/lib.rs
@@ -170,6 +170,12 @@ pub fn build(
fetch::apply_patches(recipe, &src_tree)?;
}
+ // Receta efectiva para resolver las FASES: puede ganar un `-p <pkg>` si la fuente es un
+ // workspace Cargo virtual (ver inject_cargo_package_selector). El hash `h` ya está fijado desde
+ // los flags ORIGINALES de la receta; el `-p` es resolución determinista del lab (misma fuente →
+ // mismo paquete) ⇒ no afecta la reproducibilidad ni el sidecar (que guarda la receta original).
+ let mut eff_recipe = recipe.clone();
+
// Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea
// hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock.
if matches!(detect_build_system(&src_tree), BuildSys::Cargo) {
@@ -180,6 +186,8 @@ pub fn build(
// duplicar la tabla). Esto desbloquea genéricamente los imports Rust (Etapa G) sin un patch
// por receta. Ver memoria 'cargo-recipe crate suelto'.
ensure_cargo_workspace_isolation(&src_tree)?;
+ // Extractor de workspaces (Etapa G): si el root es virtual, resuelve `-p <pkg>` del bin.
+ inject_cargo_package_selector(&mut eff_recipe, &src_tree);
tracing::info!("cargo: vendoreando deps para build offline");
fetch::vendor_cargo_deps(&src_tree)?;
}
@@ -222,7 +230,7 @@ pub fn build(
env,
};
- let phases = resolve_phases(recipe, &src_tree)?;
+ let phases = resolve_phases(&eff_recipe, &src_tree)?;
if let Some(cmd) = &phases.configure {
tracing::info!(cmd, "phase: configure");
sb.run(cmd)?;
@@ -280,6 +288,84 @@ enum BuildSys {
Unknown,
}
+/// ¿El `Cargo.toml` raíz es un MANIFIESTO VIRTUAL? (declara `[workspace]` pero NO `[package]`).
+/// Es el patrón del ecosistema CLI Rust moderno (sd, fd, …): el root sólo agrupa miembros y el
+/// binario vive en un sub-paquete. En ese caso `cargo … --bin X` solo falla: hace falta `-p <pkg>`.
+/// Un crate suelto (con `[package]`, aunque `ensure_cargo_workspace_isolation` le agregue
+/// `[workspace]`) NO es virtual ⇒ no se toca (ripgrep y el corpus quedan idénticos).
+fn cargo_root_is_virtual(src: &Path) -> bool {
+ let Ok(text) = std::fs::read_to_string(src.join("Cargo.toml")) else {
+ return false;
+ };
+ let mut has_ws = false;
+ let mut has_pkg = false;
+ for l in text.lines() {
+ let t = l.trim_start();
+ if t.starts_with("[workspace]") || t.starts_with("[workspace.") {
+ has_ws = true;
+ }
+ if t.starts_with("[package]") {
+ has_pkg = true;
+ }
+ }
+ has_ws && !has_pkg
+}
+
+/// Lee los MEMBERS del workspace (vía `cargo metadata`, la fuente autoritativa: expande globs,
+/// `[[bin]]`, bins por convención `src/bin/*.rs`, y workspaces anidados — todo lo que un parseo a
+/// mano del TOML erraría) y devuelve el NOMBRE del paquete que expone un bin llamado `bin`. Es el
+/// "extractor de workspaces" de la Etapa G: convierte un workspace virtual en un `-p <pkg>` exacto.
+fn resolve_cargo_bin_package(src: &Path, bin: &str) -> Option<String> {
+ let out = std::process::Command::new("cargo")
+ .args(["metadata", "--no-deps", "--format-version", "1"])
+ .current_dir(src)
+ .output()
+ .ok()?;
+ if !out.status.success() {
+ return None;
+ }
+ let meta: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
+ for pkg in meta.get("packages")?.as_array()? {
+ let pkg_name = pkg.get("name")?.as_str()?;
+ for tgt in pkg.get("targets")?.as_array()? {
+ let is_bin = tgt
+ .get("kind")
+ .and_then(|k| k.as_array())
+ .map(|ks| ks.iter().any(|x| x.as_str() == Some("bin")))
+ .unwrap_or(false);
+ if is_bin && tgt.get("name").and_then(|n| n.as_str()) == Some(bin) {
+ return Some(pkg_name.to_string());
+ }
+ }
+ }
+ None
+}
+
+/// Si la fuente es un workspace VIRTUAL y los flags piden `--bin X` sin `-p`, resuelve el paquete
+/// real que expone `X` y antepone `-p <pkg>`. Deja los flags intactos en cualquier otro caso
+/// (crate suelto, ya con `-p`, o bin irresoluble). Determinista (misma fuente → mismo paquete) ⇒
+/// reproducible. Sin esto, casi todo el CLI Rust moderno (workspaces virtuales) exigía `-p` a mano.
+fn inject_cargo_package_selector(recipe: &mut Recipe, src: &Path) {
+ let flags = &recipe.build.flags;
+ if flags.iter().any(|f| f == "-p" || f == "--package") {
+ return; // el autor ya eligió el paquete
+ }
+ if !cargo_root_is_virtual(src) {
+ return; // crate suelto: `--bin` solo alcanza
+ }
+ // bin = el argumento que sigue a `--bin`.
+ let Some(bin) = flags.iter().position(|f| f == "--bin").and_then(|i| flags.get(i + 1)).cloned()
+ else {
+ return;
+ };
+ if let Some(pkg) = resolve_cargo_bin_package(src, &bin) {
+ tracing::info!(%bin, %pkg, "cargo: workspace virtual ⇒ -p resuelto automáticamente");
+ let mut new_flags = vec!["-p".to_string(), pkg];
+ new_flags.extend(recipe.build.flags.iter().cloned());
+ recipe.build.flags = new_flags;
+ }
+}
+
/// Garantiza que el `Cargo.toml` raíz de la fuente declare un `[workspace]` propio, para que cargo
/// NO la considere parte del workspace del repo hammer (que la contiene en `work/sources/`). Append
/// idempotente: si ya hay `[workspace]` (crate suelto que es su propio workspace, o una receta que