use clap::{Parser, Subcommand};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::{fs, io};
use warble_claude_code::{
build_manifest,
ir::{validate_ir_version, WarbleIr},
parse_envelope, render_envelope_to_html,
slots::{resolve_ir_json, SlotSupply},
ContextInjection, HybridRealization, ModelConfig, NativeMcpDescriptor, NativePurpose,
NativeSessionScope, RenderFlavor, RenderOptions, DEFAULT_CONTEXT_INJECTION,
};
use warble_cli::{
asset_dir_for_ir, blast_radius_for_project, check_compliance_ir_version,
compile_project_to_ir_with_assets, default_component_sources_with_hub_version, gate,
land_assets, BuiltinContextResolver, ComponentSource,
};
use warble_eval_compare::{compare, CompareRequest, CompareResult};
use warble_eval_runner::{
build_candidate_yaml, candidates_header, effective_record_answers, format_ablation,
format_compliance, format_gate, format_monitor_report, format_pareto, run_ablation, run_eval,
run_gate, score_compliance, score_monitor_pair, stamp_context_version, verify_context,
AblationConfig, Backend, CaptureInput, CaseFilter, ComplianceIr, ComplianceTrace, Freshness,
Report, RunConfig,
};
use warble_vercel::{
emit_vercel, known_target_names, parse_provider_fragments,
validate_ir_version as validate_vercel_ir_version, ProviderFragment,
TargetId as VercelTargetId, DEFAULT_TARGET,
};
mod context_check;
mod mcp_serve;
#[derive(Parser)]
#[command(
name = "warble",
version,
about = "Warble — declarative data-agent behavior framework"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
CheckContext,
Compile {
project_dir: PathBuf,
#[arg(short, long)]
out: PathBuf,
#[arg(long = "component-dir")]
component_dir: Vec<PathBuf>,
#[arg(long = "hub-dir", conflicts_with = "hub_version")]
hub_dir: Option<PathBuf>,
#[arg(long = "hub-version", conflicts_with = "hub_dir")]
hub_version: Option<String>,
#[arg(long = "overlay")]
overlay: Option<PathBuf>,
},
ProduceSession {
ir: PathBuf,
#[arg(long)]
component: String,
#[arg(long)]
host_contract: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long = "slot")]
slot: Vec<String>,
},
Dispatch {
ir: PathBuf,
#[arg(long, default_value = "claude-code:headless")]
target: String,
#[arg(long)]
out: PathBuf,
#[arg(long = "render-flavor", default_value = "programmatic")]
render_flavor: String,
#[arg(long = "models-config")]
models_config: Option<PathBuf>,
#[arg(long, default_value = "opus")]
strong: String,
#[arg(long, default_value = "haiku")]
cheap: String,
#[arg(long, default_value = "sonnet")]
orchestrator: String,
#[arg(long = "hybrid-realization", default_value = "bash-script")]
hybrid_realization: String,
#[arg(long)]
purpose: Option<String>,
#[arg(long = "native-scope")]
native_scope: Option<PathBuf>,
#[arg(long = "native-mcp")]
native_mcp: Option<PathBuf>,
#[arg(long = "native-host")]
native_host: Option<PathBuf>,
#[arg(long = "provider")]
provider: Vec<PathBuf>,
#[arg(long = "host-contract")]
host_contract: Option<PathBuf>,
#[arg(long = "slot", value_name = "NAME=VARIANT")]
slot: Vec<String>,
},
Render {
input: String,
#[arg(short, long)]
out: PathBuf,
#[arg(long)]
title: Option<String>,
},
Manifest {
ir: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
},
McpServe {
#[arg(long)]
steps: PathBuf,
},
#[command(subcommand)]
Eval(EvalCommand),
BlastRadius {
project_dir: PathBuf,
#[arg(long)]
node: String,
#[arg(long = "max-severity-rank")]
max_severity_rank: Option<u32>,
#[arg(long = "max-downstream")]
max_downstream: Option<usize>,
#[arg(long, default_value = "")]
protected: String,
},
}
#[derive(Subcommand)]
enum EvalCommand {
Compare,
Run {
#[arg(long)]
project: PathBuf,
#[arg(long = "agent-dir")]
agent_dir: Option<PathBuf>,
#[arg(long)]
ir: Option<PathBuf>,
#[arg(long)]
golden: PathBuf,
#[arg(long, default_value = "opus,haiku")]
models: String,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long, default_value_t = 1)]
parallel: usize,
#[arg(long, default_value = "")]
tags: String,
#[arg(long)]
sample: Option<String>,
#[arg(long = "no-cache")]
no_cache: bool,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long, default_value_t = 1)]
samples: usize,
#[arg(long = "record-answers")]
record_answers: bool,
#[arg(long, value_enum, default_value_t = Backend::ClaudeCodeCli)]
backend: Backend,
#[arg(long = "max-turns")]
max_turns: Option<u32>,
#[arg(long = "models-config")]
models_config: Option<PathBuf>,
#[arg(long)]
strong: Option<String>,
#[arg(long)]
cheap: Option<String>,
#[arg(long)]
orchestrator: Option<String>,
},
Ablate {
#[arg(long)]
project: PathBuf,
#[arg(long)]
ir: PathBuf,
#[arg(long)]
golden: PathBuf,
#[arg(long, default_value = "claude-code:headless")]
target: String,
#[arg(long = "models-config")]
models_config: Option<PathBuf>,
#[arg(long, default_value = "cheap,strong")]
sweep: String,
#[arg(long = "base-tier", default_value = "strong")]
base_tier: String,
#[arg(long = "accuracy-drop-tolerance", default_value = "0.0")]
accuracy_drop_tolerance: f64,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long, default_value_t = 1)]
parallel: usize,
#[arg(long, default_value = "")]
tags: String,
#[arg(long)]
sample: Option<String>,
#[arg(long = "no-cache")]
no_cache: bool,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = Backend::ClaudeCodeCli)]
backend: Backend,
},
VerifyContext {
#[arg(long)]
golden: PathBuf,
#[arg(long)]
project: PathBuf,
#[arg(long)]
stamp: bool,
#[arg(long)]
strict: bool,
#[arg(long = "agent-dir")]
agent_dir: Option<PathBuf>,
#[arg(long, default_value = "haiku")]
models: String,
#[arg(long)]
reverify: bool,
},
Capture {
#[arg(long)]
question: String,
#[arg(long)]
id: String,
#[arg(long, default_value = "-")]
result: String,
#[arg(long = "match", default_value = "set")]
match_mode: String,
#[arg(long, default_value = "0.01")]
tolerance: f64,
#[arg(long, default_value = "")]
tags: String,
#[arg(long = "context-version")]
context_version: Option<String>,
#[arg(long)]
dataset: Option<String>,
#[arg(long)]
out: Option<PathBuf>,
},
Gate {
#[arg(long)]
baseline: PathBuf,
#[arg(long)]
report: PathBuf,
#[arg(long, default_value = "0.0")]
tolerance: f64,
#[arg(long, value_enum)]
backend: Option<Backend>,
},
MonitorReport {
#[arg(long)]
manifest: PathBuf,
#[arg(long = "clean-report")]
clean_report: PathBuf,
#[arg(long = "injected-report")]
injected_report: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
},
Compliance {
#[arg(long)]
trace: PathBuf,
#[arg(long)]
ir: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
let result = match cli.command {
Command::CheckContext => context_check::run(),
Command::Compile {
project_dir,
out,
component_dir,
hub_dir,
hub_version,
overlay,
} => run_compile(
&project_dir,
&out,
&component_dir,
hub_dir.as_deref(),
hub_version.as_deref(),
overlay.as_deref(),
),
Command::ProduceSession {
ir,
component,
host_contract,
out,
slot,
} => run_produce_session(&ir, &host_contract, &component, &out, &slot),
Command::Dispatch {
ir,
target,
out,
render_flavor,
models_config,
strong,
cheap,
orchestrator,
hybrid_realization,
purpose,
native_scope,
native_mcp,
native_host,
provider,
host_contract,
slot,
} => run_dispatch(
&ir,
&target,
&out,
&render_flavor,
models_config.as_deref(),
strong,
cheap,
orchestrator,
&hybrid_realization,
purpose.as_deref(),
native_scope.as_deref(),
native_mcp.as_deref(),
native_host.as_deref(),
&provider,
host_contract.as_deref(),
&slot,
),
Command::Render { input, out, title } => run_render(&input, &out, title.as_deref()),
Command::Manifest { ir, out } => run_manifest(&ir, out.as_deref()),
Command::McpServe { steps } => mcp_serve::run_mcp_serve(&steps),
Command::Eval(EvalCommand::Compare) => return run_eval_compare(),
Command::Eval(EvalCommand::Capture {
question,
id,
result,
match_mode,
tolerance,
tags,
context_version,
dataset,
out,
}) => run_eval_capture(
&question,
&id,
&result,
&match_mode,
tolerance,
&tags,
context_version.as_deref(),
dataset.as_deref(),
out.as_deref(),
),
Command::Eval(EvalCommand::Gate {
baseline,
report,
tolerance,
backend,
}) => return run_eval_gate(&baseline, &report, tolerance, backend),
Command::Eval(EvalCommand::MonitorReport {
manifest,
clean_report,
injected_report,
out,
}) => {
return run_eval_monitor_report(
&manifest,
&clean_report,
&injected_report,
out.as_deref(),
)
}
Command::Eval(EvalCommand::Compliance { trace, ir, out }) => {
return run_eval_compliance(&trace, &ir, out.as_deref())
}
Command::Eval(EvalCommand::VerifyContext {
golden,
project,
stamp,
strict,
agent_dir,
models,
reverify,
}) => {
return run_eval_verify_context(
&golden,
&project,
stamp,
strict,
agent_dir.as_deref(),
&models,
reverify,
)
}
Command::Eval(EvalCommand::Run {
project,
agent_dir,
ir,
golden,
models,
out,
parallel,
tags,
sample,
no_cache,
cache_dir,
samples,
record_answers,
backend,
max_turns,
models_config,
strong,
cheap,
orchestrator,
}) => run_eval_run(
&project,
agent_dir.as_deref(),
ir.as_deref(),
&golden,
&models,
out.as_deref(),
parallel,
&tags,
sample.as_deref(),
no_cache,
cache_dir,
samples,
record_answers,
backend,
max_turns,
models_config.as_deref(),
strong,
cheap,
orchestrator,
),
Command::Eval(EvalCommand::Ablate {
project,
ir,
golden,
target,
models_config,
sweep,
base_tier,
accuracy_drop_tolerance,
out,
parallel,
tags,
sample,
no_cache,
cache_dir,
backend,
}) => run_eval_ablate(
&project,
&ir,
&golden,
&target,
models_config.as_deref(),
&sweep,
&base_tier,
accuracy_drop_tolerance,
out.as_deref(),
parallel,
&tags,
sample.as_deref(),
no_cache,
cache_dir,
backend,
),
Command::BlastRadius {
project_dir,
node,
max_severity_rank,
max_downstream,
protected,
} => {
return run_blast_radius(
&project_dir,
&node,
max_severity_rank,
max_downstream,
&protected,
)
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("error: {message}");
ExitCode::FAILURE
}
}
}
fn run_compile(
project_dir: &Path,
out: &Path,
extra_component_dirs: &[PathBuf],
hub_dir: Option<&Path>,
hub_version: Option<&str>,
overlay: Option<&Path>,
) -> Result<(), String> {
let mut sources = match hub_dir {
Some(hub_dir) => vec![
ComponentSource::local(project_dir.join("components")),
ComponentSource::hub(hub_dir),
],
None => default_component_sources_with_hub_version(project_dir, hub_version)?,
};
sources.extend(
extra_component_dirs
.iter()
.map(|dir| ComponentSource::local(dir.clone())),
);
let (ir, assets) =
compile_project_to_ir_with_assets(project_dir, &sources, &BuiltinContextResolver, overlay)?;
let rendered = serde_json::to_string_pretty(&ir).map_err(|e| e.to_string())?;
fs::write(out, rendered).map_err(|e| format!("failed to write {}: {e}", out.display()))?;
warble_cli::write_assets(&asset_dir_for_ir(out), &assets)
}
fn run_produce_session(
ir: &Path,
host: &Path,
component: &str,
out: &Path,
slots: &[String],
) -> Result<(), String> {
use std::io::{Read, Write};
let read_bounded = |path: &Path| -> Result<String, String> {
let mut bytes = String::new();
fs::File::open(path)
.map_err(|e| format!("cannot open producer input: {e}"))?
.take(warble_claude_code::session::MAX_INPUT_BYTES as u64 + 1)
.read_to_string(&mut bytes)
.map_err(|e| format!("cannot read producer input: {e}"))?;
Ok(bytes)
};
let plan = warble_claude_code::session::produce_session(
&read_bounded(ir)?,
&read_bounded(host)?,
component,
&parse_slot_flags(slots)?,
)
.map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec(&plan).map_err(|e| e.to_string())?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut output = options
.open(out)
.map_err(|e| format!("cannot create session plan: {e}"))?;
if let Err(error) = output.write_all(&bytes).and_then(|()| output.sync_all()) {
drop(output);
let _ = fs::remove_file(out);
return Err(format!("cannot write session plan: {error}"));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_dispatch(
ir_path: &Path,
target: &str,
out: &Path,
render_flavor: &str,
models_config: Option<&Path>,
strong: String,
cheap: String,
orchestrator: String,
hybrid_realization: &str,
purpose: Option<&str>,
native_scope_path: Option<&Path>,
native_mcp_path: Option<&Path>,
native_host_path: Option<&Path>,
provider_paths: &[PathBuf],
host_contract: Option<&Path>,
slot_flags: &[String],
) -> Result<(), String> {
let slots = parse_slot_flags(slot_flags)?;
let purpose = purpose
.map(|value| {
NativePurpose::parse(value).ok_or_else(|| {
format!(
"unknown --purpose '{value}' (expected: analysis, setup, context_enrichment)"
)
})
})
.transpose()?;
if purpose.is_some() && target != "claude-code:interactive" && target != "codex:interactive" {
return Err("--purpose is supported only by native interactive targets".to_string());
}
let native_scope = match (purpose, native_scope_path) {
(Some(_), Some(path)) => {
Some(NativeSessionScope::from_file(path).map_err(|e| e.to_string())?)
}
(Some(_), None) => {
return Err(
"native Sessions purpose requires a server-derived --native-scope descriptor"
.to_string(),
)
}
(None, Some(_)) => {
return Err("--native-scope requires a native Sessions --purpose".to_string())
}
(None, None) => None,
};
let native_mcp = match (purpose, native_mcp_path) {
(Some(_), Some(path)) => {
Some(NativeMcpDescriptor::from_file(path).map_err(|e| e.to_string())?)
}
(Some(_), None) | (None, None) => None,
(None, Some(_)) => {
return Err("--native-mcp requires a native Sessions --purpose".to_string())
}
};
let native_host = if let Some(path) = native_host_path {
if !slots.is_empty() {
return Err("--native-host does not support --slot".into());
}
if native_mcp.is_none() {
return Err("--native-host requires --native-mcp".into());
}
Some(
warble_claude_code::native_host::NativeHost::prepare(
&read_file(ir_path)?,
&read_file(path)?,
target,
purpose.ok_or("--native-host requires --purpose analysis")?,
native_scope
.as_ref()
.ok_or("--native-host requires --native-scope")?,
)
.map_err(|e| e.to_string())?,
)
} else {
None
};
let hosted_native = native_host.is_some();
let flavor = RenderFlavor::parse(render_flavor).ok_or_else(|| {
format!("unknown --render-flavor '{render_flavor}' (expected: programmatic, prompt)")
})?;
let hybrid = HybridRealization::parse(hybrid_realization).ok_or_else(|| {
format!(
"unknown --hybrid-realization '{hybrid_realization}' (expected: bash-script, mcp-server)"
)
})?;
if is_vercel_target(target) {
return run_vercel_dispatch(ir_path, target, out, provider_paths, host_contract, &slots);
}
if host_contract.is_some() {
return Err("--host-contract is supported only by vercel targets".to_string());
}
if target == "codex:interactive" {
if !provider_paths.is_empty() {
return Err("--provider is not supported for the codex:interactive target".to_string());
}
let ir = match native_host.as_ref() {
Some(host) => host.ir().clone(),
None => load_ir(ir_path, &slots)?,
};
warble_claude_code::emit_codex_interactive_with_host(
&ir,
out,
purpose,
native_scope,
native_mcp,
native_host,
)
.map_err(|e| e.to_string())?;
return if hosted_native {
Ok(())
} else {
land_ir_assets(ir_path, out)
};
}
let models = match models_config {
Some(path) => ModelConfig::from_yaml(&read_file(path)?).map_err(|e| e.to_string())?,
None => ModelConfig::from_flags(strong, cheap, orchestrator),
};
let ir = match native_host.as_ref() {
Some(host) => host.ir().clone(),
None => load_ir(ir_path, &slots)?,
};
let context = ContextInjection::from_ir(&ir, DEFAULT_CONTEXT_INJECTION);
let providers = load_claude_code_provider_fragments(provider_paths)?;
warble_claude_code::emit_claude_code_with_native_host(
&ir,
out,
target,
flavor,
&models,
hybrid,
&context,
&providers,
purpose,
native_scope,
native_mcp,
native_host,
)
.map_err(|e| e.to_string())?;
if hosted_native {
Ok(())
} else {
land_ir_assets(ir_path, out)
}
}
fn is_vercel_target(target: &str) -> bool {
target == "vercel" || target.starts_with("vercel:")
}
fn run_vercel_dispatch(
ir_path: &Path,
target: &str,
out: &Path,
provider_paths: &[PathBuf],
host_contract: Option<&Path>,
slots: &SlotSupply,
) -> Result<(), String> {
let target_id = if target == "vercel" {
DEFAULT_TARGET
} else {
VercelTargetId::parse(target).ok_or_else(|| {
format!(
"unknown --target '{target}' (expected: vercel, {})",
known_target_names().join(", ")
)
})?
};
let providers = load_provider_fragments(provider_paths)?;
if let Some(path) = host_contract {
if !slots.is_empty() {
return Err("hosted vercel does not support --slot".to_string());
}
warble_vercel::hosted::emit_hosted_vercel(
&read_file(ir_path)?,
&read_file(path)?,
target_id,
out,
&providers,
)
.map_err(|e| e.to_string())?;
return Ok(());
}
let ir = load_vercel_ir(ir_path, slots)?;
emit_vercel(&ir, target_id, out, &providers).map_err(|e| e.to_string())?;
land_ir_assets(ir_path, out)
}
fn load_claude_code_provider_fragments(
paths: &[PathBuf],
) -> Result<Vec<warble_claude_code::ProviderFragment>, String> {
let mut fragments = Vec::new();
for path in paths {
let raw = read_file(path)?;
let parsed = warble_claude_code::parse_provider_fragments(&raw)
.map_err(|e| format!("failed to parse provider fragment {}: {e}", path.display()))?;
fragments.extend(parsed);
}
Ok(fragments)
}
fn load_provider_fragments(paths: &[PathBuf]) -> Result<Vec<ProviderFragment>, String> {
let mut fragments = Vec::new();
for path in paths {
let raw = read_file(path)?;
let parsed = parse_provider_fragments(&raw)
.map_err(|e| format!("failed to parse provider fragment {}: {e}", path.display()))?;
fragments.extend(parsed);
}
Ok(fragments)
}
fn run_render(input: &str, out: &Path, title: Option<&str>) -> Result<(), String> {
let raw = read_input(input)?;
let envelope = parse_envelope(&unwrap_cli_result(&raw)).map_err(|e| e.to_string())?;
let options = RenderOptions {
title: title.map(|t| t.to_string()),
};
let html = render_envelope_to_html(&envelope, &options);
fs::write(out, html).map_err(|e| format!("failed to write {}: {e}", out.display()))?;
eprintln!(
"warble render: wrote {} ({} block(s))",
out.display(),
envelope.blocks.len()
);
Ok(())
}
fn unwrap_cli_result(raw: &str) -> String {
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(raw) {
if let Some(serde_json::Value::String(result)) = map.get("result") {
return result.clone();
}
}
raw.to_string()
}
fn run_manifest(ir_path: &Path, out: Option<&Path>) -> Result<(), String> {
let ir = load_ir_unresolved(ir_path)?;
let manifest = build_manifest(&ir);
let json = format!(
"{}\n",
serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?
);
match out {
Some(path) => {
fs::write(path, json)
.map_err(|e| format!("failed to write {}: {e}", path.display()))?;
eprintln!("warble manifest: wrote {}", path.display());
Ok(())
}
None => {
print!("{json}");
Ok(())
}
}
}
fn run_eval_compare() -> ExitCode {
let mut input = String::new();
if let Err(e) = io::stdin().read_to_string(&mut input) {
eprintln!("{}", compare_error(format!("failed to read stdin: {e}")));
return ExitCode::FAILURE;
}
match serde_json::from_str::<CompareRequest>(&input) {
Ok(request) => {
let result = compare(&request);
println!(
"{}",
serde_json::to_string(&result).expect("result serializes")
);
if result.pass {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
Err(e) => {
eprintln!("{}", compare_error(format!("invalid input JSON: {e}")));
ExitCode::FAILURE
}
}
}
fn compare_error(reason: String) -> String {
serde_json::to_string(&CompareResult {
pass: false,
reason,
})
.expect("result serializes")
}
#[allow(clippy::too_many_arguments)]
fn run_eval_capture(
question: &str,
id: &str,
result: &str,
match_mode: &str,
tolerance: f64,
tags: &str,
context_version: Option<&str>,
dataset: Option<&str>,
out: Option<&Path>,
) -> Result<(), String> {
let raw = read_input(result)?;
let result_text = unwrap_cli_result(&raw);
let tags: Vec<String> = tags
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let input = CaptureInput {
id,
question,
match_mode,
numeric_tolerance: tolerance,
tags,
result_text: &result_text,
};
let block = build_candidate_yaml(&input)?;
match out {
Some(path) => {
let body = if path.exists() {
let existing = read_file(path)?;
format!("{existing}{block}")
} else {
format!("{}{block}", candidates_header(dataset, context_version))
};
fs::write(path, body)
.map_err(|e| format!("failed to write {}: {e}", path.display()))?;
eprintln!(
"warble eval capture: candidate '{id}' → {} (review, then move into the golden set)",
path.display()
);
Ok(())
}
None => {
print!("{}{block}", candidates_header(dataset, context_version));
Ok(())
}
}
}
fn run_eval_gate(
baseline: &Path,
report: &Path,
tolerance: f64,
backend: Option<Backend>,
) -> ExitCode {
let load = |path: &Path| -> Result<Report, String> {
let raw = read_file(path)?;
serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))
};
let (mut base, mut cur) = match (load(baseline), load(report)) {
(Ok(b), Ok(c)) => (b, c),
(Err(e), _) | (_, Err(e)) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
base.backfill_legacy();
cur.backfill_legacy();
if let Some(want) = backend {
if base.backend != want || cur.backend != want {
eprintln!(
"error: --backend {want} requested, but baseline is '{}' and candidate is '{}'",
base.backend, cur.backend
);
return ExitCode::FAILURE;
}
}
let result = run_gate(&base, &cur, tolerance);
print!("{}", format_gate(&result));
if result.passed {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn run_eval_monitor_report(
manifest_path: &Path,
clean_path: &Path,
injected_path: &Path,
out: Option<&Path>,
) -> ExitCode {
let load_report = |path: &Path| -> Result<Report, String> {
let raw = read_file(path)?;
serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))
};
let manifest = match read_file(manifest_path) {
Ok(raw) => raw,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let (clean, injected) = match (load_report(clean_path), load_report(injected_path)) {
(Ok(clean), Ok(injected)) => (clean, injected),
(Err(e), _) | (_, Err(e)) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let report = match score_monitor_pair(&manifest, &clean, &injected) {
Ok(report) => report,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
print!("{}", format_monitor_report(&report));
if let Some(path) = out {
let json = match serde_json::to_string_pretty(&report) {
Ok(json) => format!("{json}\n"),
Err(e) => {
eprintln!("error: failed to serialize monitor report: {e}");
return ExitCode::FAILURE;
}
};
if let Err(e) = fs::write(path, json) {
eprintln!("error: failed to write {}: {e}", path.display());
return ExitCode::FAILURE;
}
println!("report → {}", path.display());
}
if report.passed {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn run_eval_compliance(trace_path: &Path, ir_path: &Path, out: Option<&Path>) -> ExitCode {
let trace: ComplianceTrace = match read_file(trace_path)
.and_then(|raw| serde_json::from_str(&raw).map_err(|e| format!("parse trace: {e}")))
{
Ok(t) => t,
Err(e) => {
eprintln!("error: failed to read trace {}: {e}", trace_path.display());
return ExitCode::FAILURE;
}
};
let ir_raw = match read_file(ir_path) {
Ok(raw) => raw,
Err(e) => {
eprintln!("error: failed to read IR {}: {e}", ir_path.display());
return ExitCode::FAILURE;
}
};
if let Err(e) = check_compliance_ir_version(&ir_raw, ir_path) {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
let ir: ComplianceIr = match serde_json::from_str(&ir_raw).map_err(|e| format!("parse IR: {e}"))
{
Ok(i) => i,
Err(e) => {
eprintln!("error: failed to read IR {}: {e}", ir_path.display());
return ExitCode::FAILURE;
}
};
let report = score_compliance(&trace, &ir);
print!("{}", format_compliance(&report));
if let Some(out_path) = out {
match serde_json::to_string_pretty(&report) {
Ok(json) => {
if let Err(e) = fs::write(out_path, json) {
eprintln!("error: failed to write {}: {e}", out_path.display());
return ExitCode::FAILURE;
}
println!("report → {}", out_path.display());
}
Err(e) => {
eprintln!("error: failed to serialize report: {e}");
return ExitCode::FAILURE;
}
}
}
if report.compliant {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
#[allow(clippy::too_many_arguments)]
fn run_eval_verify_context(
golden: &Path,
project: &Path,
stamp: bool,
strict: bool,
agent_dir: Option<&Path>,
models: &str,
reverify: bool,
) -> ExitCode {
let golden_text = match read_file(golden) {
Ok(t) => t,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let meta: serde_yaml::Value = match serde_yaml::from_str(&golden_text) {
Ok(m) => m,
Err(e) => {
eprintln!("error: parse golden: {e}");
return ExitCode::FAILURE;
}
};
let context_version = meta
.get("context_version")
.and_then(|v| v.as_str())
.map(str::to_string);
let dataset = meta
.get("dataset")
.and_then(|v| v.as_str())
.map(str::to_string);
let result = match verify_context(context_version.as_deref(), project) {
Ok(r) => r,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
println!("\n=== Warble eval — context version check ===");
println!("golden: {}", golden.display());
println!("current MDL: {}", result.current_sha);
println!("freshness: {:?} — {}", result.freshness, result.detail);
if stamp {
match stamp_context_version(&golden_text, &result.current_sha, dataset.as_deref()) {
Ok(updated) => {
if let Err(e) = fs::write(golden, updated) {
eprintln!("error: write {}: {e}", golden.display());
return ExitCode::FAILURE;
}
println!("stamped: context_version re-pinned to current MDL SHA.");
return ExitCode::SUCCESS;
}
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
}
}
let is_failure = matches!(result.freshness, Freshness::Stale)
|| (strict && matches!(result.freshness, Freshness::Unpinned));
if is_failure && reverify {
match agent_dir {
Some(dir) => {
println!("\nreverify: re-running goldens against the changed MDL …");
let cfg = RunConfig {
project: project.to_path_buf(),
agent_dir: Some(dir.to_path_buf()),
ir_path: None,
golden_path: golden.to_path_buf(),
models: models.split(',').map(|s| s.trim().to_string()).collect(),
out: None,
parallel: 1,
filter: CaseFilter::default(),
no_cache: true,
cache_dir: None,
samples: 1,
record_answers: false,
backend: Backend::default(),
max_turns: None,
tier_models: None,
};
match run_eval(&cfg) {
Ok(report) => {
print!("{}", format_pareto(&report));
println!(
"\nreverify: cases that now FAIL against their pinned expected are the \
diff — re-confirm the new result or retire the golden."
);
}
Err(e) => {
eprintln!("error: reverify run failed: {e}");
return ExitCode::FAILURE;
}
}
}
None => eprintln!("note: --reverify needs --agent-dir; skipping the re-run."),
}
}
if is_failure {
println!("\nCONTEXT STALE — re-verify the goldens, then --stamp to re-pin (or retire).");
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn resolve_tier_models(
models_config: Option<&Path>,
strong: Option<String>,
cheap: Option<String>,
orchestrator: Option<String>,
) -> Result<Option<ModelConfig>, String> {
if let Some(path) = models_config {
return Ok(Some(
ModelConfig::from_yaml(&read_file(path)?).map_err(|e| e.to_string())?,
));
}
match (strong, cheap, orchestrator) {
(None, None, None) => Ok(None),
(Some(strong), Some(cheap), Some(orchestrator)) => {
Ok(Some(ModelConfig::from_flags(strong, cheap, orchestrator)))
}
(strong, cheap, orchestrator) => Err(format!(
"a differentiated tier binding needs all three of --strong/--cheap/--orchestrator \
(or --models-config); got strong={strong:?}, cheap={cheap:?}, orchestrator={orchestrator:?} — \
pass all three, or omit all of them to keep the --models sweep"
)),
}
}
#[allow(clippy::too_many_arguments)]
fn run_eval_run(
project: &Path,
agent_dir: Option<&Path>,
ir: Option<&Path>,
golden: &Path,
models: &str,
out: Option<&Path>,
parallel: usize,
tags: &str,
sample: Option<&str>,
no_cache: bool,
cache_dir: Option<PathBuf>,
samples: usize,
record_answers: bool,
backend: Backend,
max_turns: Option<u32>,
models_config: Option<&Path>,
strong: Option<String>,
cheap: Option<String>,
orchestrator: Option<String>,
) -> Result<(), String> {
let record_answers = effective_record_answers(record_answers, samples);
let tier_models = resolve_tier_models(models_config, strong, cheap, orchestrator)?;
let cfg = RunConfig {
project: project.to_path_buf(),
agent_dir: agent_dir.map(Path::to_path_buf),
ir_path: ir.map(Path::to_path_buf),
golden_path: golden.to_path_buf(),
models: models.split(',').map(|s| s.trim().to_string()).collect(),
out: out.map(Path::to_path_buf),
parallel,
filter: CaseFilter::from_flags(tags, sample)?,
no_cache,
cache_dir,
samples,
record_answers,
backend,
max_turns,
tier_models,
};
let report = run_eval(&cfg)?;
print!("{}", format_pareto(&report));
if let Some(path) = out {
let json = format!(
"{}\n",
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
);
fs::write(path, json).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
println!("\nreport → {}", path.display());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_eval_ablate(
project: &Path,
ir: &Path,
golden: &Path,
target: &str,
models_config: Option<&Path>,
sweep: &str,
base_tier: &str,
accuracy_drop_tolerance: f64,
out: Option<&Path>,
parallel: usize,
tags: &str,
sample: Option<&str>,
no_cache: bool,
cache_dir: Option<PathBuf>,
backend: Backend,
) -> Result<(), String> {
let cfg = AblationConfig {
project: project.to_path_buf(),
ir_path: ir.to_path_buf(),
golden_path: golden.to_path_buf(),
target: target.to_string(),
models_config_path: models_config.map(Path::to_path_buf),
sweep_tiers: sweep.split(',').map(|s| s.trim().to_string()).collect(),
base_tier: base_tier.to_string(),
accuracy_drop_tolerance,
out: out.map(Path::to_path_buf),
parallel,
filter: CaseFilter::from_flags(tags, sample)?,
no_cache,
cache_dir,
backend,
};
let report = run_ablation(&cfg)?;
print!("{}", format_ablation(&report));
if let Some(path) = out {
let json = format!(
"{}\n",
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
);
fs::write(path, json).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
println!("\nreport → {}", path.display());
}
Ok(())
}
fn run_blast_radius(
project_dir: &Path,
node: &str,
max_severity_rank: Option<u32>,
max_downstream: Option<usize>,
protected: &str,
) -> ExitCode {
let protected: Vec<String> = protected
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let threshold = gate::GateThreshold {
max_severity_rank,
max_downstream,
protected,
};
let impact = match blast_radius_for_project(project_dir, node) {
Ok(r) => r,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let (decision, reason) = gate::decide(node, impact.as_ref(), &threshold);
let output = serde_json::json!({
"seed": node,
"downstream": impact.as_ref().map(|i| i.downstream.clone()).unwrap_or_default(),
"severity": impact.as_ref().map(|i| serde_json::json!({
"rank": i.severity.rank,
"name": i.severity.name,
})),
"decision": decision.as_str(),
"reason": reason,
});
match serde_json::to_string_pretty(&output) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("error: failed to serialize blast-radius result: {e}");
return ExitCode::FAILURE;
}
}
match decision {
gate::GateDecision::Allow => ExitCode::SUCCESS,
gate::GateDecision::Escalate => ExitCode::from(10),
gate::GateDecision::Block => ExitCode::from(11),
}
}
fn load_ir_unresolved(path: &Path) -> Result<WarbleIr, String> {
let raw = read_file(path)?;
let ir: WarbleIr = serde_json::from_str(&raw)
.map_err(|e| format!("failed to parse IR {}: {e}", path.display()))?;
validate_ir_version(&ir).map_err(|e| e.to_string())?;
Ok(ir)
}
fn land_ir_assets(ir_path: &Path, out: &Path) -> Result<(), String> {
let raw = read_file(ir_path)?;
let ir: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("failed to parse IR {}: {e}", ir_path.display()))?;
land_assets(&ir, ir_path, out).map(|_| ())
}
fn load_ir(path: &Path, slots: &SlotSupply) -> Result<WarbleIr, String> {
let raw = resolve_ir_slots(path, slots)?;
let ir: WarbleIr = serde_json::from_str(&raw)
.map_err(|e| format!("failed to parse IR {}: {e}", path.display()))?;
validate_ir_version(&ir).map_err(|e| e.to_string())?;
Ok(ir)
}
fn resolve_ir_slots(path: &Path, slots: &SlotSupply) -> Result<String, String> {
let raw = read_file(path)?;
resolve_ir_json(&raw, slots)
.map_err(|e| format!("failed to resolve prompt slots in {}: {e}", path.display()))
}
fn parse_slot_flags(flags: &[String]) -> Result<SlotSupply, String> {
let mut supply = SlotSupply::new();
for flag in flags {
let Some((name, variant)) = flag.split_once('=') else {
return Err(format!(
"--slot '{flag}' must be NAME=VARIANT, or NAME= to remove a conditional slot"
));
};
if name.is_empty() {
return Err(format!("--slot '{flag}' has an empty slot name"));
}
let value = if variant.is_empty() {
None
} else {
Some(variant.to_string())
};
if supply.insert(name.to_string(), value).is_some() {
return Err(format!("--slot {name} was given more than once"));
}
}
Ok(supply)
}
fn load_vercel_ir(path: &Path, slots: &SlotSupply) -> Result<warble_vercel::ir::WarbleIr, String> {
let raw = resolve_ir_slots(path, slots)?;
let ir: warble_vercel::ir::WarbleIr = serde_json::from_str(&raw)
.map_err(|e| format!("failed to parse IR {}: {e}", path.display()))?;
validate_vercel_ir_version(&ir).map_err(|e| e.to_string())?;
Ok(ir)
}
fn read_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
}
fn read_input(input: &str) -> Result<String, String> {
if input == "-" {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.map_err(|e| format!("failed to read stdin: {e}"))?;
Ok(buf)
} else {
read_file(Path::new(input))
}
}
#[cfg(test)]
mod slot_flag_tests {
use super::parse_slot_flags;
#[test]
fn slot_flags_distinguish_a_chosen_variant_from_a_removed_slot() {
let supply = parse_slot_flags(&["charter=terse".to_string(), "verification=".to_string()])
.expect("parses");
assert_eq!(supply.get("charter"), Some(&Some("terse".to_string())));
assert_eq!(supply.get("verification"), Some(&None));
}
#[test]
fn slot_flags_refuse_a_missing_separator_an_empty_name_and_a_repeat() {
assert!(parse_slot_flags(&["charter".to_string()])
.expect_err("no separator")
.contains("must be NAME=VARIANT"));
assert!(parse_slot_flags(&["=terse".to_string()])
.expect_err("empty name")
.contains("empty slot name"));
assert!(parse_slot_flags(&["a=b".to_string(), "a=c".to_string()])
.expect_err("repeat")
.contains("was given more than once"));
}
}
#[cfg(test)]
mod resolve_tier_models_tests {
use super::resolve_tier_models;
#[test]
fn no_flags_at_all_resolves_to_none() {
let resolved = resolve_tier_models(None, None, None, None).expect("not an error");
assert!(
resolved.is_none(),
"absent flags must preserve the --models sweep path"
);
}
#[test]
fn three_inline_flags_produce_a_config_with_the_three_distinct_values() {
let resolved = resolve_tier_models(
None,
Some("sonnet".to_string()),
Some("haiku".to_string()),
Some("sonnet".to_string()),
)
.expect("three flags is a valid binding")
.expect("three flags must resolve to Some");
assert_eq!(resolved.require("strong").unwrap(), "sonnet");
assert_eq!(resolved.require("cheap").unwrap(), "haiku");
assert_eq!(resolved.require("orchestrator").unwrap(), "sonnet");
}
#[test]
fn partial_inline_flags_fail_loudly_with_an_actionable_message() {
let err = resolve_tier_models(None, Some("sonnet".to_string()), None, None)
.expect_err("a partial binding must be rejected");
assert!(
err.contains("--strong") && err.contains("--cheap") && err.contains("--orchestrator"),
"error should name all three flags so the user knows what to add: {err}"
);
}
#[test]
fn models_config_wins_over_inline_flags_even_when_both_given() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("models.yaml");
std::fs::write(
&path,
"tiers:\n strong: opus\n cheap: haiku\n orchestrator: sonnet\n",
)
.expect("write models.yaml");
let resolved = resolve_tier_models(
Some(path.as_path()),
Some("ignored-strong".to_string()),
Some("ignored-cheap".to_string()),
Some("ignored-orchestrator".to_string()),
)
.expect("models-config is a valid binding")
.expect("models-config must resolve to Some");
assert_eq!(resolved.require("strong").unwrap(), "opus");
assert_eq!(resolved.require("cheap").unwrap(), "haiku");
assert_eq!(resolved.require("orchestrator").unwrap(), "sonnet");
}
}