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, emit_claude_code_with_native_purpose, emit_codex_interactive,
ir::{validate_ir_version, WarbleIr},
parse_envelope, render_envelope_to_html, ContextInjection, ContextInjectionMode,
HybridRealization, ModelConfig, NativeMcpDescriptor, NativePurpose, NativeSessionScope,
RenderFlavor, RenderOptions,
};
use warble_cli::{
blast_radius_for_project, check_compliance_ir_version, compile_project_to_ir_with_sources,
default_component_sources_with_hub_version, gate, 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_mdl_context::read_knowledge_rules;
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 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 {
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>,
},
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 = "context-injection", default_value = "schema-only")]
context_injection: String,
#[arg(long = "context-project")]
context_project: Option<PathBuf>,
#[arg(long)]
purpose: Option<String>,
#[arg(long = "native-scope")]
native_scope: Option<PathBuf>,
#[arg(long = "native-mcp")]
native_mcp: Option<PathBuf>,
#[arg(long = "provider")]
provider: Vec<PathBuf>,
},
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")]
max_severity: Option<String>,
#[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::Compile {
project_dir,
out,
component_dir,
hub_dir,
hub_version,
} => run_compile(
&project_dir,
&out,
&component_dir,
hub_dir.as_deref(),
hub_version.as_deref(),
),
Command::Dispatch {
ir,
target,
out,
render_flavor,
models_config,
strong,
cheap,
orchestrator,
hybrid_realization,
context_injection,
context_project,
purpose,
native_scope,
native_mcp,
provider,
} => run_dispatch(
&ir,
&target,
&out,
&render_flavor,
models_config.as_deref(),
strong,
cheap,
orchestrator,
&hybrid_realization,
&context_injection,
context_project.as_deref(),
purpose.as_deref(),
native_scope.as_deref(),
native_mcp.as_deref(),
&provider,
),
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,
max_downstream,
protected,
} => {
return run_blast_radius(
&project_dir,
&node,
max_severity.as_deref(),
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>,
) -> 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 = compile_project_to_ir_with_sources(project_dir, &sources)?;
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()))
}
#[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,
context_injection: &str,
context_project: Option<&Path>,
purpose: Option<&str>,
native_scope_path: Option<&Path>,
native_mcp_path: Option<&Path>,
provider_paths: &[PathBuf],
) -> Result<(), String> {
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())
}
};
if purpose.is_some() && context_project.is_some() {
return Err(
"--context-project is not supported for native Sessions purposes; the server-owned scope selects the project"
.to_string(),
);
}
let context_mode = ContextInjectionMode::parse(context_injection).ok_or_else(|| {
format!(
"unknown --context-injection '{context_injection}' (expected: schema-only, schema+knowledge)"
)
})?;
if is_vercel_target(target) {
return run_vercel_dispatch(ir_path, target, out, provider_paths);
}
if target == "codex:interactive" {
if !provider_paths.is_empty() {
return Err("--provider is not supported for the codex:interactive target".to_string());
}
let ir = load_ir(ir_path)?;
return emit_codex_interactive(&ir, out, purpose, native_scope, native_mcp)
.map_err(|e| e.to_string());
}
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)"
)
})?;
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 = load_ir(ir_path)?;
let knowledge = if context_mode == ContextInjectionMode::SchemaWithKnowledge {
let ir_dir = ir_path.parent().unwrap_or_else(|| Path::new("."));
let project_dir = context_project
.map(Path::to_path_buf)
.unwrap_or_else(|| ir_dir.join(&ir.context_binding.project));
if !project_dir.is_dir() {
return Err(format!(
"--context-injection schema+knowledge cannot resolve bound project {} from the IR location; pass --context-project <project-root>",
project_dir.display()
));
}
let loaded = read_knowledge_rules(&project_dir).map_err(|e| {
format!(
"failed to read knowledge rules from bound project {}: {e}",
project_dir.display()
)
})?;
if loaded.used_legacy {
eprintln!(
"warning: bound project uses deprecated instructions.md; move it to knowledge/rules/*.md"
);
}
Some(loaded.content)
} else {
None
};
let context = ContextInjection::from_ir(&ir, context_mode, knowledge);
let providers = load_claude_code_provider_fragments(provider_paths)?;
emit_claude_code_with_native_purpose(
&ir,
out,
target,
flavor,
&models,
hybrid,
&context,
&providers,
purpose,
native_scope,
native_mcp,
)
.map_err(|e| e.to_string())
}
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],
) -> 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 ir = load_vercel_ir(ir_path)?;
let providers = load_provider_fragments(provider_paths)?;
emit_vercel(&ir, target_id, out, &providers)
.map(|_| ())
.map_err(|e| e.to_string())
}
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(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: Option<&str>,
max_downstream: Option<usize>,
protected: &str,
) -> ExitCode {
let max_severity = match max_severity {
Some(s) => match gate::parse_severity(s) {
Some(sev) => Some(sev),
None => {
eprintln!(
"error: unknown --max-severity '{s}' (expected: none, compatibility, structural, semantic)"
);
return ExitCode::FAILURE;
}
},
None => None,
};
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,
max_downstream,
protected,
};
let radius = match blast_radius_for_project(project_dir, node) {
Ok(r) => r,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let (decision, reason) = gate::decide(&radius, &threshold);
let output = serde_json::json!({
"seed": radius.seed,
"downstream": radius.downstream,
"severity": gate::severity_str(radius.severity),
"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(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 load_vercel_ir(path: &Path) -> Result<warble_vercel::ir::WarbleIr, String> {
let raw = read_file(path)?;
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 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");
}
}