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>
This commit is contained in:
2026-06-21 08:45:04 -04:00
co-authored by Claude Opus 4.8
parent 6e4d8da779
commit a157b8c455
3 changed files with 89 additions and 1 deletions
Generated
+1
View File
@@ -524,6 +524,7 @@ dependencies = [
"anyhow",
"hammer-core",
"hex",
"serde_json",
"sha2",
"tempfile",
"thiserror",
+1
View File
@@ -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
+87 -1
View File
@@ -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