use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use clap::{Args, Subcommand};
use mlua_swarm::{
Compiler, LuaInProcessSpawnerFactory, OperatorSpawnerFactory, RustFnInProcessSpawnerFactory,
SpawnerRegistry, SubprocessProcessSpawnerFactory,
};
use mlua_swarm_cli::dsl;
#[derive(Debug, Args)]
pub struct BpArgs {
#[command(subcommand)]
cmd: BpCmd,
}
#[derive(Debug, Subcommand)]
enum BpCmd {
Build(BuildArgs),
New(NewArgs),
}
#[derive(Debug, Args)]
struct BuildArgs {
script: PathBuf,
#[arg(short = 'o', long = "out")]
out: Option<PathBuf>,
#[arg(long)]
register: bool,
#[arg(long)]
server: Option<String>,
}
#[derive(Debug, Args)]
struct NewArgs {
template: String,
name: String,
#[arg(long)]
stages: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
operator: Option<String>,
#[arg(long)]
binding: Option<String>,
#[arg(short = 'o', long = "out")]
out: Option<PathBuf>,
}
const DEFAULT_SERVER: &str = "127.0.0.1:7777";
pub async fn run(args: BpArgs) -> Result<()> {
match args.cmd {
BpCmd::Build(build_args) => run_build(build_args).await,
BpCmd::New(new_args) => run_new(new_args),
}
}
async fn run_build(args: BuildArgs) -> Result<()> {
let script = std::fs::read_to_string(&args.script)
.with_context(|| format!("reading {}", args.script.display()))?;
let bp_value = dsl::build_bp_from_script(&script)
.with_context(|| format!("building Blueprint from {}", args.script.display()))?;
match compile_lint(&bp_value, &args.script) {
Ok(LintReport::Ok { agents, operators }) => {
eprintln!("compile lint: OK ({agents} agent(s), {operators} operator(s) checked)");
}
Ok(LintReport::Skipped { reason }) => {
eprintln!("compile lint: skipped — {reason}");
}
Err(e) => {
let msg = format!("{e:#}");
if let Some(hint) = fix_hint_from_compile_error(&msg) {
eprintln!();
eprintln!("fix hint ({}):", hint.kind);
eprintln!(" {}", hint.reason);
eprintln!();
eprintln!(" suggested patch:");
for line in hint.patch_suggestion.lines() {
eprintln!(" {line}");
}
if let Some(docs) = &hint.docs_ref {
eprintln!();
eprintln!(" see: {docs}");
}
}
return Err(e);
}
}
let out_str = serde_json::to_string_pretty(&bp_value)?;
match &args.out {
Some(path) => {
std::fs::write(path, &out_str)
.with_context(|| format!("writing {}", path.display()))?;
}
None => println!("{out_str}"),
}
if args.register {
let outcome = register(&bp_value, args.server.as_deref()).await?;
eprintln!(
"register: {} -> HTTP {}: {}",
outcome.url, outcome.http_status, outcome.body
);
}
Ok(())
}
const DEFAULT_BINDING: &str = "claude";
const DEFAULT_OPERATOR: &str = "main-ai";
const DEFAULT_PIPELINE_STAGES: &[&str] = &["stage1", "stage2"];
const DEFAULT_VERDICT_STAGES: [&str; 3] = ["analyze", "review", "publish"];
const DEFAULT_SINGLE_AGENT: &str = "solo";
fn run_new(args: NewArgs) -> Result<()> {
let out = render_template(&args)?;
match &args.out {
Some(path) => {
std::fs::write(path, &out).with_context(|| format!("writing {}", path.display()))?;
eprintln!("mse bp new: wrote {} ({} bytes)", path.display(), out.len());
}
None => print!("{out}"),
}
Ok(())
}
fn render_template(args: &NewArgs) -> Result<String> {
render_template_by_kind(
&args.template,
&args.name,
args.stages.as_deref(),
args.agent.as_deref(),
args.operator.as_deref(),
args.binding.as_deref(),
)
}
pub(crate) fn render_template_by_kind(
template: &str,
name: &str,
stages: Option<&str>,
agent: Option<&str>,
operator: Option<&str>,
binding: Option<&str>,
) -> Result<String> {
let operator = operator.unwrap_or(DEFAULT_OPERATOR);
let binding = binding.unwrap_or(DEFAULT_BINDING);
match template {
"pipeline" => Ok(render_pipeline_template(
name,
&parse_stages(stages, DEFAULT_PIPELINE_STAGES),
operator,
binding,
)),
"single" => Ok(render_single_template(
name,
agent.unwrap_or(DEFAULT_SINGLE_AGENT),
operator,
binding,
)),
"verdict" => Ok(render_verdict_template(
name,
&parse_verdict_stages(stages),
operator,
binding,
)),
other => Err(anyhow!(
"unknown template '{other}': accepted = pipeline / single / verdict"
)),
}
}
fn parse_stages(raw: Option<&str>, default: &[&str]) -> Vec<String> {
match raw {
Some(s) => s
.split(',')
.map(|part| part.trim())
.filter(|part| !part.is_empty())
.map(String::from)
.collect(),
None => default.iter().map(|s| (*s).to_string()).collect(),
}
}
fn parse_verdict_stages(raw: Option<&str>) -> [String; 3] {
let supplied = parse_stages(raw, &[]);
let mut out = DEFAULT_VERDICT_STAGES.map(String::from);
for (slot, val) in out.iter_mut().zip(supplied) {
*slot = val;
}
out
}
fn render_pipeline_template(
name: &str,
stages: &[String],
operator: &str,
binding: &str,
) -> String {
let stages: &[String] = if stages.is_empty() {
return render_pipeline_template(
name,
&DEFAULT_PIPELINE_STAGES
.iter()
.map(|s| (*s).to_string())
.collect::<Vec<_>>(),
operator,
binding,
);
} else {
stages
};
let init_ctx_sample = stages
.iter()
.map(|stage| format!("{stage} = \"...\""))
.collect::<Vec<_>>()
.join(", ");
let mut out = String::new();
out.push_str("-- Scaffolded by `mse bp new pipeline` (GH #62 Axis A).\n");
out.push_str("-- Every mandatory field is pre-filled: `halted_at` (compile-lint\n");
out.push_str("-- default), each operator agent's `profile.worker_binding` (WS\n");
out.push_str("-- thin-path requirement, GH #61), the operator's `kind` (main_ai,\n");
out.push_str("-- so a caller can omit `operator_kind` at swarm_run time and the\n");
out.push_str("-- BP Agent-level tier of the OperatorKind cascade routes spawns to\n");
out.push_str("-- a joined main-ai session instead of silently falling through to\n");
out.push_str("-- the Automate backend, GH #66), `strict_refs` + `strict_kind`.\n");
out.push_str("--\n");
out.push_str("-- Launch prerequisite (GH #64): the pipeline sugar reads each stage's\n");
out.push_str("-- input from `$.d.<stage_name>` and does NOT auto-chain outputs into\n");
out.push_str("-- the next stage. Seed every stage under `d` when starting a run:\n");
out.push_str("--\n");
out.push_str(&format!(
"-- swarm_run(blueprint = ..., init_ctx = {{ d = {{ {init_ctx_sample} }} }})\n"
));
out.push_str("--\n");
out.push_str("-- See `mse://guides/bp-dsl-templates` for the `$.d.<stage>` convention\n");
out.push_str("-- and recipes for hand-chaining outputs (e.g. `F.step { input = F.p \"$.<prev>\" }`).\n\n");
out.push_str("local B = require(\"bp_dsl\")\n\n");
out.push_str("local flow = B.pipeline({\n");
for stage in stages {
out.push_str(&format!(
" B.stage \"{stage}\" {{ agent = \"{stage}\" }},\n"
));
}
out.push_str(" halted_at = \"$.halted_at\",\n");
out.push_str(" done = \"$.pipeline_complete\",\n");
out.push_str("})\n\n");
out.push_str(&format!("return {{\n id = \"{name}\",\n flow = flow,\n"));
out.push_str(" agents = {\n");
for stage in stages {
out.push_str(&format!(
" {{ name = \"{stage}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: describe {stage}\", \
tools = {{}}, worker_binding = \"{binding}\" }} }},\n"
));
}
out.push_str(" },\n");
out.push_str(&format!(
" operators = {{ {{ name = \"{operator}\", kind = \"main_ai\" }} }},\n"
));
out.push_str(" strategy = { strict_refs = true, strict_kind = true },\n");
out.push_str(&format!(
" metadata = {{ description = \"TODO: describe {name}\" }},\n"
));
out.push_str("}\n");
out
}
fn render_single_template(name: &str, agent: &str, operator: &str, binding: &str) -> String {
let mut out = String::new();
out.push_str("-- Scaffolded by `mse bp new single` (GH #62 Axis A).\n");
out.push_str("-- Minimal 1-step 1-agent shape — `flow_dsl` directly, no pipeline\n");
out.push_str("-- sugar. All mandatory fields (`worker_binding`, `strict_refs`,\n");
out.push_str("-- `strict_kind`) are pre-filled. The operator's `kind` is also\n");
out.push_str("-- pre-filled as `main_ai` (GH #66) so `swarm_run` can omit\n");
out.push_str("-- `operator_kind` at launch and spawns route to a joined\n");
out.push_str("-- main-ai session.\n\n");
out.push_str("local F = require(\"flow_dsl\")\n\n");
out.push_str(&format!(
"local flow = F.step({{ id = \"{agent}\", agent = \"{agent}\", \
input = F.lit(\"\"), out = F.p(\"$.{agent}\") }})\n\n"
));
out.push_str(&format!("return {{\n id = \"{name}\",\n flow = flow,\n"));
out.push_str(" agents = {\n");
out.push_str(&format!(
" {{ name = \"{agent}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: describe {agent}\", \
tools = {{}}, worker_binding = \"{binding}\" }} }},\n"
));
out.push_str(" },\n");
out.push_str(&format!(
" operators = {{ {{ name = \"{operator}\", kind = \"main_ai\" }} }},\n"
));
out.push_str(" strategy = { strict_refs = true, strict_kind = true },\n");
out.push_str(&format!(
" metadata = {{ description = \"TODO: describe {name}\" }},\n"
));
out.push_str("}\n");
out
}
fn render_verdict_template(
name: &str,
stages: &[String; 3],
operator: &str,
binding: &str,
) -> String {
let [analyze, review, publish] = stages;
let mut out = String::new();
out.push_str("-- Scaffolded by `mse bp new verdict` (GH #62 Axis A).\n");
out.push_str(&format!(
"-- Mirrors `mse://blueprints/samples/07-dsl-pipeline`: {analyze} -> \
{review} (verdict-gated, bounded retry through fixer on BLOCKED) -> \
{publish}. All mandatory fields pre-filled.\n"
));
out.push_str("-- The operator's `kind` is also pre-filled as `main_ai` (GH #66)\n");
out.push_str("-- so `swarm_run` can omit `operator_kind` at launch and spawns\n");
out.push_str("-- route to a joined main-ai session.\n");
out.push_str("--\n");
out.push_str("-- Launch prerequisite (GH #64): the pipeline sugar reads each stage's\n");
out.push_str("-- input from `$.d.<stage_name>` and does NOT auto-chain outputs into\n");
out.push_str("-- the next stage. Seed every stage under `d` when starting a run:\n");
out.push_str("--\n");
out.push_str(&format!(
"-- swarm_run(blueprint = ..., init_ctx = {{ d = {{ {analyze} = \"...\", {review} = \"...\", {publish} = \"...\" }} }})\n"
));
out.push_str("--\n");
out.push_str("-- See `mse://guides/bp-dsl-templates` for the `$.d.<stage>` convention\n");
out.push_str("-- and recipes for hand-chaining outputs (e.g. `F.step { input = F.p \"$.<prev>\" }`).\n\n");
out.push_str("local B = require(\"bp_dsl\")\n\n");
out.push_str("local flow = B.pipeline({\n");
out.push_str(&format!(
" B.stage \"{analyze}\" {{ agent = \"{analyze}\" }},\n"
));
out.push_str(&format!(
" B.stage \"{review}\" {{\n \
agent = \"{review}\",\n \
retry = {{\n \
max = 2,\n \
fix = B.stage \"fix\" {{ agent = \"fixer\", input = B.from \"{review}\" }},\n \
}},\n }},\n"
));
out.push_str(&format!(
" B.stage \"{publish}\" {{ agent = \"{publish}\" }},\n"
));
out.push_str(" halt_on = { \"BLOCKED\" },\n");
out.push_str(" halted_at = \"$.halted_at\",\n");
out.push_str(" done = \"$.pipeline_complete\",\n");
out.push_str("})\n\n");
out.push_str(&format!("return {{\n id = \"{name}\",\n flow = flow,\n"));
out.push_str(" agents = {\n");
for stage in [analyze.as_str(), publish.as_str()] {
out.push_str(&format!(
" {{ name = \"{stage}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: describe {stage}\", \
tools = {{}}, worker_binding = \"{binding}\" }} }},\n"
));
}
out.push_str(&format!(
" {{ name = \"{review}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: stage a `verdict` part = \
`PASS` or `BLOCKED`, then finish with report body\", \
tools = {{}}, worker_binding = \"{binding}\" }},\n \
verdict = {{ channel = \"part\", values = {{ \"PASS\", \"BLOCKED\" }} }} }},\n"
));
out.push_str(&format!(
" {{ name = \"fixer\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: given the reviewer's report, \
emit a fix and reply so the review can retry\", \
tools = {{}}, worker_binding = \"{binding}\" }} }},\n"
));
out.push_str(" },\n");
out.push_str(&format!(
" operators = {{ {{ name = \"{operator}\", kind = \"main_ai\" }} }},\n"
));
out.push_str(" strategy = { strict_refs = true, strict_kind = true },\n");
out.push_str(&format!(
" metadata = {{ description = \"TODO: describe {name}\" }},\n"
));
out.push_str("}\n");
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FixHint {
pub kind: &'static str,
pub reason: String,
pub patch_suggestion: String,
pub docs_ref: Option<String>,
}
pub(crate) fn fix_hint_from_compile_error(err_msg: &str) -> Option<FixHint> {
if err_msg.contains("profile.worker_binding is required") {
let agent = extract_between(err_msg, "agent '", "'");
let reason = match agent {
Some(name) => format!(
"operator agent '{name}' has no `profile.worker_binding` — required for the WS thin-path backend (GH #61)"
),
None => "an operator agent has no `profile.worker_binding` — required for the WS thin-path backend (GH #61)"
.into(),
};
return Some(FixHint {
kind: "worker-binding-missing",
reason,
patch_suggestion:
"profile = { system_prompt = \"...\", tools = {}, worker_binding = \"claude\" }"
.into(),
docs_ref: Some("mse://guides/bp-dsl-templates".into()),
});
}
if err_msg.contains("is not a member of the declared values") {
return Some(FixHint {
kind: "verdict-value-not-in-contract",
reason: "a Branch / Loop cond literal is outside its agent's declared verdict contract (`agents[N].verdict.values`)".into(),
patch_suggestion: "either add the cond's literal to `agents[N].verdict.values`, or change the cond to a value that is already declared".into(),
docs_ref: Some("mse://guides/blueprint-authoring".into()),
});
}
if err_msg.contains("missing field `at`") || err_msg.contains("halted_at") {
return Some(FixHint {
kind: "halted-at-missing",
reason: "the flow declares a halt-on rule but has no `halted_at` sink — where should the halted-stage id land in ctx?".into(),
patch_suggestion:
"halted_at = \"$.halted_at\", -- add inside the B.pipeline { ... } block, before `done = ...`"
.into(),
docs_ref: Some("mse://guides/bp-dsl-templates".into()),
});
}
None
}
fn extract_between<'a>(s: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
let start = s.find(prefix)? + prefix.len();
let rest = s.get(start..)?;
let end = rest.find(suffix)?;
Some(&rest[..end])
}
pub(crate) enum LintReport {
Ok { agents: usize, operators: usize },
Skipped { reason: String },
}
pub(crate) fn compile_lint(bp_value: &serde_json::Value, script_path: &Path) -> Result<LintReport> {
let base = script_path.parent().unwrap_or_else(|| Path::new("."));
let default_kind = mlua_swarm::blueprint::loader::pre_read_default_agent_kind(bp_value);
let expanded = match mlua_swarm::expand_file_refs(bp_value.clone(), base, default_kind) {
Ok(v) => v,
Err(e) => {
return Ok(LintReport::Skipped {
reason: format!(
"could not resolve $file/$agent_md refs relative to {} ({e}). Only the \
static DSL shape was validated; the server resolves these refs against \
its own --blueprint-ref-base at register time.",
base.display()
),
});
}
};
let bp: mlua_swarm::Blueprint = serde_json::from_value(expanded).map_err(|e| {
anyhow!("compile lint: blueprint shape invalid after $agent_md expansion: {e}")
})?;
let registry = lint_registry(&bp);
Compiler::new(registry)
.compile(&bp)
.map_err(|e| anyhow!("compile lint FAILED: {e}"))?;
Ok(LintReport::Ok {
agents: bp.agents.len(),
operators: bp.operators.len(),
})
}
struct LintStubOperator;
#[async_trait::async_trait]
impl mlua_swarm::Operator for LintStubOperator {
async fn execute(
&self,
_ctx: &mlua_swarm::Ctx,
_system: Option<String>,
_prompt: serde_json::Value,
_worker: Option<mlua_swarm::WorkerBinding>,
_worker_token: mlua_swarm::CapToken,
) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
Ok(mlua_swarm::WorkerResult {
value: serde_json::Value::Null,
ok: true,
})
}
fn requires_worker_binding(&self) -> bool {
true
}
}
fn lint_registry(bp: &mlua_swarm::Blueprint) -> SpawnerRegistry {
let mut reg = SpawnerRegistry::new();
reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(SubprocessProcessSpawnerFactory));
reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(RustFnInProcessSpawnerFactory::new()));
reg.register::<LuaInProcessSpawnerFactory>(Arc::new(LuaInProcessSpawnerFactory::new()));
let op_factory = OperatorSpawnerFactory::new();
for op in &bp.operators {
op_factory.register_operator(op.name.clone(), Arc::new(LintStubOperator));
}
reg.register::<OperatorSpawnerFactory>(Arc::new(op_factory));
reg
}
pub(crate) struct RegisterOutcome {
pub url: String,
pub http_status: u16,
pub body: String,
}
pub(crate) async fn register(
bp_value: &serde_json::Value,
server: Option<&str>,
) -> Result<RegisterOutcome> {
let server = server.unwrap_or(DEFAULT_SERVER);
let id = bp_value
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("register: Blueprint JSON has no top-level 'id' string field"))?;
let url = format!("http://{server}/v1/blueprints/{id}");
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(bp_value)
.send()
.await
.map_err(|e| anyhow!("register: request to {url} failed: {e}"))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(anyhow!("register: {url} returned HTTP {status}: {body}"));
}
Ok(RegisterOutcome {
url,
http_status: status.as_u16(),
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn build_and_compile_lint(rendered: &str) -> Result<LintReport> {
let bp_value = dsl::build_bp_from_script(rendered)?;
compile_lint(&bp_value, Path::new("/tmp/nonexistent.bp.lua"))
}
#[test]
fn pipeline_template_round_trips_with_defaults() {
let rendered =
render_template_by_kind("pipeline", "roundtrip-pipe", None, None, None, None)
.expect("render must succeed with defaults");
assert!(rendered.contains("worker_binding = \"claude\""));
assert!(rendered.contains("halted_at = \"$.halted_at\""));
assert!(rendered.contains("kind = \"main_ai\""));
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
match report {
LintReport::Ok { agents, operators } => {
assert_eq!(agents, DEFAULT_PIPELINE_STAGES.len());
assert_eq!(operators, 1);
}
LintReport::Skipped { reason } => panic!("expected Ok, got Skipped: {reason}"),
}
}
#[test]
fn pipeline_template_documents_init_ctx_seeding() {
let rendered = render_template_by_kind(
"pipeline",
"seed-doc",
Some("ingest,transform,emit"),
None,
None,
None,
)
.expect("render must succeed");
assert!(
rendered.contains("$.d.<stage_name>"),
"header must name the input-path convention verbatim"
);
assert!(
rendered.contains(
"init_ctx = { d = { ingest = \"...\", transform = \"...\", emit = \"...\" } }"
),
"header must ship a concrete init_ctx sample tied to the actual stage names"
);
assert!(
rendered.contains("mse://guides/bp-dsl-templates"),
"header must link to the guide covering the convention"
);
}
#[test]
fn all_templates_pre_declare_operator_kind_main_ai() {
for template in ["pipeline", "single", "verdict"] {
let rendered =
render_template_by_kind(template, "op-kind-check", None, None, None, None)
.expect("render must succeed with defaults");
assert!(
rendered.contains("operators = { { name = \"main-ai\", kind = \"main_ai\" } }"),
"{template} template must emit an operator entry with `kind = \"main_ai\"` pre-declared, got: {rendered}"
);
}
}
#[test]
fn pipeline_template_honours_stages_operator_binding_flags() {
let rendered = render_template_by_kind(
"pipeline",
"roundtrip-pipe-3",
Some("greet,echo,farewell"),
None,
Some("primary"),
Some("claude-lite"),
)
.expect("render must succeed with custom flags");
assert!(rendered.contains("worker_binding = \"claude-lite\""));
assert!(rendered.contains("operator_ref = \"primary\""));
assert_eq!(rendered.matches("kind = \"operator\"").count(), 3);
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 3,
operators: 1
}
));
}
#[test]
fn single_template_round_trips_with_defaults() {
let rendered =
render_template_by_kind("single", "roundtrip-single", None, None, None, None)
.expect("render must succeed with defaults");
assert!(rendered.contains(&format!("worker_binding = \"{DEFAULT_BINDING}\"")));
assert!(rendered.contains(&format!("operator_ref = \"{DEFAULT_OPERATOR}\"")));
assert!(rendered.contains(&format!("agent = \"{DEFAULT_SINGLE_AGENT}\"")));
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 1,
operators: 1
}
));
}
#[test]
fn verdict_template_round_trips_with_defaults() {
let rendered =
render_template_by_kind("verdict", "roundtrip-verdict", None, None, None, None)
.expect("render must succeed with defaults");
assert_eq!(rendered.matches("kind = \"operator\"").count(), 4);
assert!(rendered.contains("verdict = { channel = \"part\""));
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 4,
operators: 1
}
));
}
#[test]
fn verdict_template_documents_init_ctx_seeding() {
let rendered =
render_template_by_kind("verdict", "seed-doc-verdict", None, None, None, None)
.expect("render must succeed with defaults");
assert!(
rendered.contains("$.d.<stage_name>"),
"verdict header must name the input-path convention verbatim"
);
let [analyze, review, publish] = &DEFAULT_VERDICT_STAGES;
assert!(
rendered.contains(&format!(
"init_ctx = {{ d = {{ {analyze} = \"...\", {review} = \"...\", {publish} = \"...\" }} }}"
)),
"verdict header must ship a concrete init_ctx sample tied to the 3 canonical stage names"
);
assert!(
rendered.contains("mse://guides/bp-dsl-templates"),
"verdict header must link to the guide covering the convention"
);
}
#[test]
fn verdict_template_stage_override_stays_3_slot() {
let rendered =
render_template_by_kind("verdict", "rv-partial", Some("scan"), None, None, None)
.expect("render must succeed with partial stages");
assert!(rendered.contains("B.stage \"scan\""));
assert!(rendered.contains(&format!("B.stage \"{}\"", DEFAULT_VERDICT_STAGES[1])));
assert!(rendered.contains(&format!("B.stage \"{}\"", DEFAULT_VERDICT_STAGES[2])));
let over =
render_template_by_kind("verdict", "rv-over", Some("a,b,c,d,e"), None, None, None)
.expect("render must succeed with over-supplied stages");
assert!(!over.contains("B.stage \"d\""));
assert!(!over.contains("B.stage \"e\""));
}
#[test]
fn unknown_template_returns_error_naming_accepted_list() {
let err = render_template_by_kind("bogus", "x", None, None, None, None)
.expect_err("unknown template must error");
let msg = format!("{err:#}");
assert!(msg.contains("unknown template 'bogus'"));
assert!(msg.contains("pipeline"));
assert!(msg.contains("single"));
assert!(msg.contains("verdict"));
}
#[test]
fn fix_hint_worker_binding_extracts_agent_name_and_names_kind() {
let msg = "compile lint FAILED: agent 'greeter' spec invalid: \
profile.worker_binding is required for this operator backend. Fix by either: \
(a) if authoring the Blueprint JSON directly, ...";
let hint = fix_hint_from_compile_error(msg).expect("worker_binding hint must fire");
assert_eq!(hint.kind, "worker-binding-missing");
assert!(hint.reason.contains("greeter"));
assert!(hint
.patch_suggestion
.contains("worker_binding = \"claude\""));
assert_eq!(
hint.docs_ref.as_deref(),
Some("mse://guides/bp-dsl-templates")
);
}
#[test]
fn fix_hint_worker_binding_reason_falls_back_when_no_agent_quoted() {
let msg =
"compile lint FAILED: profile.worker_binding is required for this operator backend.";
let hint = fix_hint_from_compile_error(msg).expect("worker_binding hint must fire");
assert!(hint.reason.contains("operator agent"));
assert!(hint.reason.contains("`profile.worker_binding`"));
}
#[test]
fn fix_hint_verdict_contract_mismatch_names_the_contract_field() {
let msg = "compile lint FAILED: value 'NOT_DECLARED' is not a member of the declared values [\"PASS\", \"BLOCKED\"]";
let hint = fix_hint_from_compile_error(msg).expect("verdict hint must fire");
assert_eq!(hint.kind, "verdict-value-not-in-contract");
assert!(hint.reason.contains("`agents[N].verdict.values`"));
assert!(hint.patch_suggestion.contains("add the cond's literal"));
}
#[test]
fn fix_hint_halted_at_fires_on_missing_field_at() {
let msg =
"compile lint FAILED: missing field `at` (hint: fetch the Blueprint JSON Schema...)";
let hint = fix_hint_from_compile_error(msg).expect("halted_at hint must fire");
assert_eq!(hint.kind, "halted-at-missing");
assert!(hint
.patch_suggestion
.contains("halted_at = \"$.halted_at\""));
}
#[test]
fn fix_hint_returns_none_for_unknown_lint_shape() {
assert!(
fix_hint_from_compile_error("some new lint the mapping doesn't know about").is_none()
);
assert!(fix_hint_from_compile_error("").is_none());
}
#[test]
fn extract_between_returns_first_match_only() {
assert_eq!(extract_between("agent 'a' 'b'", "agent '", "'"), Some("a"));
assert_eq!(extract_between("no prefix here", "agent '", "'"), None);
assert_eq!(extract_between("agent 'unclosed", "agent '", "'"), None);
}
#[test]
fn pipeline_template_with_empty_stages_flag_falls_back_to_defaults() {
let rendered = render_template_by_kind("pipeline", "rp-empty", Some(""), None, None, None)
.expect("render must succeed and fall back");
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: n,
operators: 1
} if n == DEFAULT_PIPELINE_STAGES.len()
));
}
}