feat(swm): source_patch modela tarballs además de git (Fase 4)
Mutation::SourcePatch y RecipeInline ganan campos repo/commit XOR tarball/sha256
(Option, serde-default ⇒ compat con .swm git existentes), resueltos por
swm::swm_source_kind con la misma regla que recipe::Source::kind. swm_bridge
sintetiza la receta según el modo; hammer export reconstruye fuentes tarball
como source_patch en vez de caer a file_drop (provenance fina recuperada).
Prompt del traductor y roadmap actualizados. Tests nuevos para tarball + XOR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0f42c38106e4e3202ae1005b89f8bfc7415b7f69 · por Sergio · 2026-06-14T03:06:35Z (hace 4 meses) · padre 7c099afe4 · ver el árbol en este commit
Delta semántico · 24
crates/hammer-agent/src/orchestrator.rs
crates/hammer-agent/src/translator_claude.rs
crates/hammer-agent/tests/client_against_stub_bus.rs
crates/hammer-build/src/swm_bridge.rs
crates/hammer-cli/src/main.rs
crates/hammer-core/src/proto.rs
crates/hammer-core/src/swm.rs
crates/hammerd/src/bus.rs
crates/hammerd/tests/bus_e2e.rs
diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs
index 2d7cba8b..de4be4ce 100644
--- a/crates/hammer-agent/src/orchestrator.rs
+++ b/crates/hammer-agent/src/orchestrator.rs
@@ -154,6 +154,8 @@ impl<T: IntentTranslator> Orchestrator<T> {
Mutation::SourcePatch {
repo,
commit,
+ tarball,
+ sha256,
patch,
patch_url: _,
build,
@@ -176,6 +178,8 @@ impl<T: IntentTranslator> Orchestrator<T> {
name: name.clone(),
repo: repo.clone(),
commit: commit.clone(),
+ tarball: tarball.clone(),
+ sha256: sha256.clone(),
patch: patch.clone(),
compiler: build.compiler.clone(),
target: build.target.clone(),
@@ -567,8 +571,10 @@ mod tests {
swm_version: 1,
base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
mutations: vec![Mutation::SourcePatch {
- repo: "git://x".into(),
- commit: "abc".into(),
+ repo: Some("git://x".into()),
+ commit: Some("abc".into()),
+ tarball: None,
+ sha256: None,
patch: None,
patch_url: None,
build: hammer_core::swm::SwmBuild {
diff --git a/crates/hammer-agent/src/translator_claude.rs b/crates/hammer-agent/src/translator_claude.rs
index 0f146d2f..7a033ff6 100644
--- a/crates/hammer-agent/src/translator_claude.rs
+++ b/crates/hammer-agent/src/translator_claude.rs
@@ -146,6 +146,8 @@ const SYSTEM_PROMPT: &str = r#"Eres un traductor que convierte intenciones human
3. source_patch — compila desde fuente y la inyecta.
{"type":"source_patch","repo":"git://...","commit":"<sha>","build":{"compiler":"zig-cc","target":"x86_64-linux-musl","link":"static","flags":[]},"target_bin":"/usr/bin/foo"}
+ - Origen git (repo+commit) O tarball (tarball+sha256), exactamente uno; p. ej.
+ {"type":"source_patch","tarball":"https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz","sha256":"<hex>","build":{...},"target_bin":"/usr/bin/grep"}
- `target_bin` ruta absoluta del binario producido.
4. init_rule — registra un servicio.
diff --git a/crates/hammer-agent/tests/client_against_stub_bus.rs b/crates/hammer-agent/tests/client_against_stub_bus.rs
index 000580e4..009530be 100644
--- a/crates/hammer-agent/tests/client_against_stub_bus.rs
+++ b/crates/hammer-agent/tests/client_against_stub_bus.rs
@@ -84,7 +84,10 @@ fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver<Event>) {
let response = match cmd {
Command::Compile { recipe } => Event::BuildReady {
recipe: recipe.name.clone(),
- artifact: format!("b3:stub-{}", recipe.commit),
+ artifact: format!(
+ "b3:stub-{}",
+ recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc")
+ ),
},
Command::Query { what, .. } => Event::QueryResult {
what,
@@ -133,8 +136,10 @@ fn client_handshake_compile_and_async_modified() {
let recipe = RecipeInline {
name: "grep".into(),
- repo: "git://example/grep.git".into(),
- commit: "abc123".into(),
+ repo: Some("git://example/grep.git".into()),
+ commit: Some("abc123".into()),
+ tarball: None,
+ sha256: None,
patch: None,
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs
index f88cf643..511dce87 100644
--- a/crates/hammer-build/src/swm_bridge.rs
+++ b/crates/hammer-build/src/swm_bridge.rs
@@ -27,33 +27,54 @@ pub fn build_source_patch(
store: &Store,
scratch_root: Option<&Path>,
) -> hammer_core::Result<ArtifactHash> {
- let (repo, commit, patch, patch_url, build_cfg, target_bin, expected) = match mutation {
- Mutation::SourcePatch {
- repo,
- commit,
- patch,
- patch_url,
- build,
- target_bin,
- expected_hash,
- } => (repo, commit, patch, patch_url, build, target_bin, expected_hash),
- _ => {
- return Err(hammer_core::Error::Recipe(
- "build_source_patch: la mutación no es 'source_patch'".into(),
- ));
- }
- };
+ let (repo, commit, tarball, sha256, patch, patch_url, build_cfg, target_bin, expected) =
+ match mutation {
+ Mutation::SourcePatch {
+ repo,
+ commit,
+ tarball,
+ sha256,
+ patch,
+ patch_url,
+ build,
+ target_bin,
+ expected_hash,
+ } => (
+ repo, commit, tarball, sha256, patch, patch_url, build, target_bin, expected_hash,
+ ),
+ _ => {
+ return Err(hammer_core::Error::Recipe(
+ "build_source_patch: la mutación no es 'source_patch'".into(),
+ ));
+ }
+ };
+
+ // git xor tarball — misma regla que el schema (`swm_source_kind`).
+ let source_kind = hammer_core::swm::swm_source_kind(
+ repo.as_deref(),
+ commit.as_deref(),
+ tarball.as_deref(),
+ sha256.as_deref(),
+ )
+ .map_err(hammer_core::Error::Recipe)?;
let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf();
let recipe_dir = scratch.join("swm-recipes");
std::fs::create_dir_all(&recipe_dir)?;
- // Materializamos el patch a disco bajo un nombre derivado del commit (estable: si el .swm
- // se reaplica, reusamos el mismo archivo y el hash de la receta no depende de
+ // Clave estable del origen para nombrar artefactos a disco: el commit (git) o el
+ // sha256 (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
+ let source_key: &str = match &source_kind {
+ hammer_core::SourceKind::Git { commit, .. } => commit,
+ hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
+ };
+
+ // Materializamos el patch a disco bajo un nombre derivado de la clave de origen (estable:
+ // si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no depende de
// aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que
// no vengan ambos.
let mut patches: Vec<String> = Vec::new();
- let patch_path = recipe_dir.join(format!("{commit}.patch"));
+ let patch_path = recipe_dir.join(format!("{source_key}.patch"));
if let Some(text) = patch {
std::fs::write(&patch_path, text.as_bytes())?;
patches.push(patch_path.to_string_lossy().into_owned());
@@ -64,14 +85,7 @@ pub fn build_source_patch(
}
let name = derive_name(target_bin);
- let recipe = synthesize_recipe(
- &name,
- commit,
- repo,
- build_cfg,
- patches,
- &recipe_dir,
- )?;
+ let recipe = synthesize_recipe(&name, &source_kind, build_cfg, patches, &recipe_dir)?;
let hash = build(&recipe, cfg, store)?;
if let Some(want) = expected {
@@ -102,24 +116,33 @@ fn derive_name(target_bin: &str) -> String {
fn synthesize_recipe(
name: &str,
- commit: &str,
- repo: &str,
+ source: &hammer_core::SourceKind<'_>,
build_cfg: &SwmBuild,
patches: Vec<String>,
base_dir: &Path,
) -> hammer_core::Result<Recipe> {
let compiler = parse_compiler(&build_cfg.compiler)?;
let link = parse_link(&build_cfg.link)?;
+ // Sección `[source]` y sufijo de versión según el modo de origen. El validador de
+ // `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente.
+ let (version_key, source_block) = match source {
+ hammer_core::SourceKind::Git { repo, commit } => (
+ *commit,
+ format!("repo = \"{repo}\"\ncommit = \"{commit}\"\n"),
+ ),
+ hammer_core::SourceKind::Tarball { url, sha256 } => (
+ *sha256,
+ format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n"),
+ ),
+ };
// Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
let toml_text = format!(
r#"
name = "{name}"
-version = "swm-{commit_short}"
+version = "swm-{version_short}"
[source]
-repo = "{repo}"
-commit = "{commit}"
-
+{source_block}
[build]
compiler = "{compiler}"
target = "{target}"
@@ -127,9 +150,8 @@ link = "{link}"
flags = []
"#,
name = name,
- commit_short = &commit[..commit.len().min(12)],
- repo = repo,
- commit = commit,
+ version_short = &version_key[..version_key.len().min(12)],
+ source_block = source_block,
compiler = compiler.as_str(),
target = build_cfg.target,
link = link.as_str(),
@@ -199,8 +221,10 @@ mod tests {
let d = tempfile::tempdir().unwrap();
let r = synthesize_recipe(
"grep",
- "a1b2c3d4e5f6a7b8c9",
- "git://example/grep.git",
+ &hammer_core::SourceKind::Git {
+ repo: "git://example/grep.git",
+ commit: "a1b2c3d4e5f6a7b8c9",
+ },
&fake_swm_build(),
vec![],
d.path(),
@@ -218,6 +242,31 @@ mod tests {
}
}
+ #[test]
+ fn synthesize_recipe_tarball() {
+ let d = tempfile::tempdir().unwrap();
+ let r = synthesize_recipe(
+ "grep",
+ &hammer_core::SourceKind::Tarball {
+ url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz",
+ sha256: "abcdef0123456789",
+ },
+ &fake_swm_build(),
+ vec![],
+ d.path(),
+ )
+ .unwrap();
+ assert_eq!(r.name, "grep");
+ assert!(r.version.starts_with("swm-abcdef012345"));
+ match r.source.kind().unwrap() {
+ hammer_core::SourceKind::Tarball { url, sha256 } => {
+ assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz");
+ assert_eq!(sha256, "abcdef0123456789");
+ }
+ _ => panic!("modo tarball esperado"),
+ }
+ }
+
#[test]
fn parse_compiler_and_link() {
assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
@@ -249,8 +298,10 @@ mod tests {
std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap();
let commit = "deadbeefcafe";
let m = Mutation::SourcePatch {
- repo: "git://x".into(),
- commit: commit.into(),
+ repo: Some("git://x".into()),
+ commit: Some(commit.into()),
+ tarball: None,
+ sha256: None,
patch: None,
patch_url: Some(format!("file://{}", patch_src.display())),
build: fake_swm_build(),
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 7f69d9fb..3a208779 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -1054,25 +1054,14 @@ fn build_export_mutations(
.map(|p| p.display().to_string())
.unwrap_or_else(|| "/".into());
- // Convertimos la Recipe a los campos que SourcePatch necesita.
- let (repo, commit) = match recipe.source.kind() {
+ // Convertimos la Recipe a los campos de origen que SourcePatch necesita
+ // (git xor tarball — ambos se modelan ya, sin caer a file_drop).
+ let (repo, commit, tarball, sha256) = match recipe.source.kind() {
Ok(hammer_core::SourceKind::Git { repo, commit }) => {
- (repo.to_string(), commit.to_string())
+ (Some(repo.to_string()), Some(commit.to_string()), None, None)
}
Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => {
- // SourcePatch sólo modela el modo git. Para tarballs caemos al
- // fallback file_drop por evento — no perdemos completeness, sólo
- // provenance fina. El humano lo ve por stderr.
- eprintln!(
- "warning: artefacto {art_hash} proviene de tarball ({url} sha256={sha256}); \
- `source_patch` aún no modela tarballs, hago file_drop"
- );
- for p in &paths {
- if let Some(ev) = last_by_path.get(p) {
- fallback.push((p.clone(), *ev));
- }
- }
- continue;
+ (None, None, Some(url.to_string()), Some(sha256.to_string()))
}
Err(_) => {
stats.warnings += 1;
@@ -1105,6 +1094,8 @@ fn build_export_mutations(
mutations.push(hammer_core::Mutation::SourcePatch {
repo,
commit,
+ tarball,
+ sha256,
patch: patch_inline,
patch_url: None,
build: hammer_core::SwmBuild {
@@ -1613,8 +1604,8 @@ flags = ["--enable-foo"]
expected_hash,
..
} => {
- assert_eq!(repo, "git://example/grep.git");
- assert_eq!(commit, "deadbeef");
+ assert_eq!(repo.as_deref(), Some("git://example/grep.git"));
+ assert_eq!(commit.as_deref(), Some("deadbeef"));
assert_eq!(build.compiler, "zig-cc");
assert_eq!(build.link, "static");
assert_eq!(build.flags, vec!["--enable-foo".to_string()]);
diff --git a/crates/hammer-core/src/proto.rs b/crates/hammer-core/src/proto.rs
index 7afa8afa..d6733efd 100644
--- a/crates/hammer-core/src/proto.rs
+++ b/crates/hammer-core/src/proto.rs
@@ -63,8 +63,17 @@ pub enum Command {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecipeInline {
pub name: String,
- pub repo: String,
- pub commit: String,
+ // Origen git (modo histórico) **o** tarball — exactamente uno, igual que
+ // `swm::Mutation::SourcePatch`. `Option` con `serde(default)` para compat con clientes
+ // que sólo mandan `repo`+`commit`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub repo: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub commit: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tarball: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patch: Option<String>,
#[serde(default = "default_compiler")]
@@ -208,8 +217,10 @@ mod tests {
let c = Command::Compile {
recipe: RecipeInline {
name: "grep".into(),
- repo: "git://x/grep.git".into(),
- commit: "abc".into(),
+ repo: Some("git://x/grep.git".into()),
+ commit: Some("abc".into()),
+ tarball: None,
+ sha256: None,
patch: None,
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs
index da39ef41..3cd9a439 100644
--- a/crates/hammer-core/src/swm.rs
+++ b/crates/hammer-core/src/swm.rs
@@ -70,8 +70,19 @@ pub struct PinDiff {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Mutation {
SourcePatch {
- repo: String,
- commit: String,
+ // Origen git (modo histórico) **o** tarball — exactamente uno, igual que
+ // [`crate::recipe::Source`]. Antes sólo se modelaba git y los tarballs caían a
+ // `file_drop`; ahora `hammer export` los reconstruye como source_patch. Los campos
+ // son `Option` (con `serde(default)`) para que los `.swm` git existentes —que sólo
+ // traen `repo`+`commit`— sigan parseando sin cambios.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ repo: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ commit: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ tarball: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -200,18 +211,58 @@ impl Swm {
}
}
+/// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos
+/// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la
+/// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad.
+pub fn swm_source_kind<'a>(
+ repo: Option<&'a str>,
+ commit: Option<&'a str>,
+ tarball: Option<&'a str>,
+ sha256: Option<&'a str>,
+) -> Result<crate::recipe::SourceKind<'a>, String> {
+ use crate::recipe::SourceKind;
+ match (repo, commit, tarball, sha256) {
+ (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
+ (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
+ (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => {
+ Err("source_patch: usa repo+commit O tarball+sha256, no ambos".into())
+ }
+ _ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()),
+ }
+}
+
impl Mutation {
/// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
/// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
/// schema.
pub fn verify_schema(&self) -> Result<(), String> {
+ use crate::recipe::SourceKind;
match self {
- Mutation::SourcePatch { repo, commit, patch, patch_url, target_bin, .. } => {
- if repo.is_empty() {
- return Err("source_patch: 'repo' vacío".into());
- }
- if commit.is_empty() {
- return Err("source_patch: 'commit' vacío".into());
+ Mutation::SourcePatch {
+ repo, commit, tarball, sha256, patch, patch_url, target_bin, ..
+ } => {
+ match swm_source_kind(
+ repo.as_deref(),
+ commit.as_deref(),
+ tarball.as_deref(),
+ sha256.as_deref(),
+ )? {
+ SourceKind::Git { repo, commit } => {
+ if repo.is_empty() {
+ return Err("source_patch: 'repo' vacío".into());
+ }
+ if commit.is_empty() {
+ return Err("source_patch: 'commit' vacío".into());
+ }
+ }
+ SourceKind::Tarball { url, sha256 } => {
+ if url.is_empty() {
+ return Err("source_patch: 'tarball' vacío".into());
+ }
+ if sha256.is_empty() {
+ return Err("source_patch: 'sha256' vacío".into());
+ }
+ }
}
if patch.is_some() && patch_url.is_some() {
return Err(
@@ -422,8 +473,10 @@ mutations:
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::SourcePatch {
- repo: "git://x".into(),
- commit: "abc".into(),
+ repo: Some("git://x".into()),
+ commit: Some("abc".into()),
+ tarball: None,
+ sha256: None,
patch: None,
patch_url: None,
build: SwmBuild {
@@ -439,4 +492,74 @@ mutations:
};
swm.verify_schema().unwrap();
}
+
+ #[test]
+ fn verify_schema_source_patch_tarball() {
+ let swm = Swm {
+ swm_version: 1,
+ base: base_v("x", &[]),
+ mutations: vec![Mutation::SourcePatch {
+ repo: None,
+ commit: None,
+ tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()),
+ sha256: Some("deadbeef".into()),
+ patch: None,
+ patch_url: None,
+ build: SwmBuild {
+ compiler: "zig-cc".into(),
+ target: "x86_64-linux-musl".into(),
+ link: "static".into(),
+ flags: vec![],
+ },
+ target_bin: "/bin/grep".into(),
+ expected_hash: None,
+ }],
+ signature: None,
+ };
+ swm.verify_schema().unwrap();
+ }
+
+ #[test]
+ fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() {
+ let m = Mutation::SourcePatch {
+ repo: Some("git://x".into()),
+ commit: Some("abc".into()),
+ tarball: Some("https://x/a.tar.gz".into()),
+ sha256: Some("deadbeef".into()),
+ patch: None,
+ patch_url: None,
+ build: SwmBuild {
+ compiler: "zig-cc".into(),
+ target: "x86_64-linux-musl".into(),
+ link: "static".into(),
+ flags: vec![],
+ },
+ target_bin: "/bin/x".into(),
+ expected_hash: None,
+ };
+ let err = m.verify_schema().unwrap_err();
+ assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
+ }
+
+ #[test]
+ fn verify_schema_source_patch_rechaza_sin_origen() {
+ let m = Mutation::SourcePatch {
+ repo: None,
+ commit: None,
+ tarball: None,
+ sha256: None,
+ patch: None,
+ patch_url: None,
+ build: SwmBuild {
+ compiler: "zig-cc".into(),
+ target: "x86_64-linux-musl".into(),
+ link: "static".into(),
+ flags: vec![],
+ },
+ target_bin: "/bin/x".into(),
+ expected_hash: None,
+ };
+ let err = m.verify_schema().unwrap_err();
+ assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
+ }
}
diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index 7a538f81..0f2e98f6 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -362,6 +362,8 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
let mutation = hammer_core::swm::Mutation::SourcePatch {
repo: recipe.repo,
commit: recipe.commit,
+ tarball: recipe.tarball,
+ sha256: recipe.sha256,
patch: recipe.patch,
patch_url: None,
build: hammer_core::swm::SwmBuild {
@@ -493,8 +495,10 @@ caps = ["query", "compile", "inject", "inject-real", "init"]
t_of(&Command::Compile {
recipe: RecipeInline {
name: "x".into(),
- repo: "g".into(),
- commit: "c".into(),
+ repo: Some("g".into()),
+ commit: Some("c".into()),
+ tarball: None,
+ sha256: None,
patch: None,
compiler: "zig-cc".into(),
target: "t".into(),
diff --git a/crates/hammerd/tests/bus_e2e.rs b/crates/hammerd/tests/bus_e2e.rs
index 6a80c8b0..12799879 100644
--- a/crates/hammerd/tests/bus_e2e.rs
+++ b/crates/hammerd/tests/bus_e2e.rs
@@ -138,8 +138,10 @@ fn missing_cap_returns_error_no_cap() {
&Command::Compile {
recipe: RecipeInline {
name: "x".into(),
- repo: "git://x".into(),
- commit: "abc".into(),
+ repo: Some("git://x".into()),
+ commit: Some("abc".into()),
+ tarball: None,
+ sha256: None,
patch: None,
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index 4cd1f8f5..af9c1bc9 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -94,8 +94,10 @@ pre-requisito de validación.
- [x] Provenance en `export`: mapa artefacto→receta vía sidecar `.hammer/recipe.toml` que
`hammer-build::build` escribe dentro del artefacto antes de sellar. `hammer export`
agrupa eventos por `artifact_hash` y emite UN `source_patch` por grupo cuya receta
- sea recuperable; lo demás cae al fallback `file_drop`. Source `git` modelado;
- `tarball` cae a file_drop con warning (pendiente extender SourcePatch).
+ sea recuperable; lo demás cae al fallback `file_drop`. Source `git` **y `tarball`**
+ modelados: `Mutation::SourcePatch` (y `RecipeInline`) llevan campos `repo`/`commit`
+ **xor** `tarball`/`sha256` (resueltos por `swm::swm_source_kind`, misma regla que
+ `recipe::Source::kind`); `hammer export` reconstruye ambos sin caer a `file_drop`.
- [x] Firma `signature` (ed25519) y `TrustStore` local. `hammer_core::sign`: `KeyPair`
(genera/carga/escribe claves), `TrustStore::load(dir)` (lee `*.ed25519.pub`),
`Swm::verify_signature(&trust) → SigStatus` (`trusted`/`unknown-key`/`bad-sig`/