fix(assembler): reject unsubstituted {{placeholder}} patterns

Walks every string-valued manifest field after load and rejects values
containing `{{...}}` (conservative: requires both `{{` and `}}`). Catches
wizard bugs that would emit literal `{{MEMORY_PROJECT}}` into the
generated agent .md. Four unit tests cover role/memory_project/single-brace
accept/empty-accept cases.
This commit is contained in:
Parfii-bot 2026-04-21 04:11:12 +08:00
parent f04c9f38da
commit 60f464c0bc

View file

@ -34,5 +34,120 @@ pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
return Err("role must not be empty".into());
}
check_no_placeholders(m)?;
Ok(())
}
/// Reject manifests that still carry `{{PLACEHOLDER}}` tokens — the wizard
/// should have substituted them. Emitted literally into generated .md they
/// produce broken agents. Matches `{{...}}` conservatively (not single braces).
fn check_no_placeholders(m: &Manifest) -> Result<(), String> {
let check = |field: &str, value: &str| -> Result<(), String> {
if contains_placeholder(value) {
Err(format!(
"Unsubstituted template placeholder in field '{field}': {value}. Did the wizard skip a substitution?"
))
} else {
Ok(())
}
};
check("name", &m.name)?;
check("description", &m.description)?;
check("model", &m.model)?;
check("role", &m.role)?;
for (i, t) in m.tools.iter().enumerate() {
check(&format!("tools[{i}]"), t)?;
}
for (i, b) in m.blocks.iter().enumerate() {
check(&format!("blocks[{i}]"), b)?;
}
for (i, d) in m.domain_in.iter().enumerate() {
check(&format!("domain_in[{i}]"), d)?;
}
for (i, d) in m.forbidden_domain.iter().enumerate() {
check(&format!("forbidden_domain[{i}]"), d)?;
}
for (i, h) in m.handoff.iter().enumerate() {
check(&format!("handoff[{i}].target"), &h.target)?;
check(&format!("handoff[{i}].trigger"), &h.trigger)?;
}
for (i, o) in m.output_extra_fields.iter().enumerate() {
check(&format!("output_extra_fields[{i}]"), o)?;
}
if let Some(v) = &m.memory_project {
check("memory_project", v)?;
}
if let Some(v) = &m.project_claudemd {
check("project_claudemd", v)?;
}
if let Some(r) = &m.references {
for (i, e) in r.extra.iter().enumerate() {
check(&format!("references.extra[{i}]"), e)?;
}
}
Ok(())
}
fn contains_placeholder(s: &str) -> bool {
// Look for a `{{` with a matching `}}` later in the same string.
if let Some(start) = s.find("{{") {
if s[start + 2..].contains("}}") {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{Handoff, Manifest};
fn base() -> Manifest {
Manifest {
name: "test".into(),
description: "d".into(),
tools: vec!["Read".into()],
model: "opus".into(),
role: "r".into(),
blocks: vec!["baseline".into(), "evidence-grading".into(), "memory-protocol".into()],
domain_in: vec!["x".into()],
forbidden_domain: vec!["y".into()],
handoff: vec![Handoff { target: "a".into(), trigger: "b".into() }],
output_extra_fields: vec![],
memory_project: None,
project_claudemd: None,
references: None,
}
}
#[test]
fn rejects_placeholder_in_memory_project() {
let mut m = base();
m.memory_project = Some("{{MEMORY_PROJECT}}".into());
let err = check_no_placeholders(&m).unwrap_err();
assert!(err.contains("memory_project"), "err = {err}");
assert!(err.contains("{{MEMORY_PROJECT}}"), "err = {err}");
}
#[test]
fn accepts_single_braces() {
let mut m = base();
m.description = "hello {world}".into();
assert!(check_no_placeholders(&m).is_ok());
}
#[test]
fn accepts_empty_manifest() {
assert!(check_no_placeholders(&base()).is_ok());
}
#[test]
fn rejects_placeholder_in_role() {
let mut m = base();
m.role = "do {{THING}}".into();
assert!(check_no_placeholders(&m).is_err());
}
}