pub mod discovery;
pub mod invocation;
pub mod result;
pub mod schema;
use anyhow::Result;
use self::invocation::InvocationInput;
use self::result::CheckResult;
pub(crate) use self::discovery::find_skill_file;
pub use self::result::VerifyReport;
#[derive(Debug, Clone)]
pub struct VerifyInput {
pub root: std::path::PathBuf,
pub spawn_root: std::path::PathBuf,
pub cli_command: Option<Vec<String>>,
pub debug: bool,
}
pub fn run(input: &VerifyInput) -> Result<VerifyReport> {
let root = &input.root;
let mut report = VerifyReport::default();
for check in discovery::run(root)? {
report.push(check);
}
let skill_path = find_skill_file(root);
let skill_md = skill_path
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.unwrap_or_default();
let cli_skills = discovery::find_skill_files(root)
.into_iter()
.filter(|p| {
std::fs::read_to_string(p)
.ok()
.and_then(|s| invocation::extract_documented_invocation(&s))
.is_some()
})
.count();
if cli_skills > 1 {
report.push(CheckResult::warn(
"invocation.multi_cli",
"invocation drift checks cover every documented CLI",
format!(
"{cli_skills} skills document a CLI invocation, but invocation checks only run against the first — the others were skipped"
),
"To fix: verify is single-CLI by default; split multi-CLI plugins into one plugin per CLI, or run verify per-skill manually.",
));
}
let inv = InvocationInput::new(
root,
&input.spawn_root,
&skill_md,
input.cli_command.as_deref(),
input.debug,
);
invocation::run(&inv, &mut report)?;
Ok(report)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
Human,
Json,
}
pub fn render(report: &VerifyReport) -> String {
use self::result::Severity;
let mut out = String::new();
let (pass, warn, fail, skip) = report.counts();
for r in &report.results {
let glyph = match r.severity {
Severity::Pass => "✓",
Severity::Warn => "!",
Severity::Error => "✗",
Severity::Skipped => "·",
};
out.push_str(&format!(
"{} {} — {}\n",
glyph,
r.severity.as_str(),
r.check_name
));
if !r.message.is_empty() {
out.push_str(&format!(" {}\n", r.message));
}
if let Some(s) = &r.suggestion {
out.push_str(&format!(" {s}\n"));
}
}
let _ = &pass;
let _ = &skip;
let _ = &warn;
out.push_str(&format!(
"\n{pass} passed, {warn} warning(s), {fail} failed — discoverability score {}/100",
report.discoverability_score()
));
out.push_str(if fail > 0 {
" — verify FAILED\n"
} else {
" — verify OK\n"
});
out
}
pub fn render_json(report: &VerifyReport) -> String {
let (pass, warn, fail, skip) = report.counts();
let results: Vec<_> = report
.results
.iter()
.map(|r| {
let mut o = serde_json::json!({
"check_id": r.check_id,
"check_name": r.check_name,
"severity": r.severity.as_str(),
"message": r.message,
});
if let Some(s) = &r.suggestion {
o["suggestion"] = serde_json::Value::String(s.clone());
}
if let Some((file, line)) = &r.location {
let mut loc = serde_json::Map::new();
loc.insert("file".to_string(), serde_json::Value::String(file.clone()));
if let Some(n) = line {
loc.insert("line".to_string(), serde_json::Value::from(*n));
}
o["location"] = serde_json::Value::Object(loc);
}
o
})
.collect();
let body = serde_json::json!({
"ok": !report.has_critical_failure(),
"discoverability_score": report.discoverability_score(),
"counts": {
"pass": pass,
"warn": warn,
"fail": fail,
"skip": skip,
},
"results": results,
});
serde_json::to_string_pretty(&body).expect("verify report serializes to JSON")
}