pub mod discovery;
pub mod fix;
pub mod invocation;
pub mod result;
pub mod schema;
use anyhow::Result;
use self::invocation::InvocationInput;
use self::result::CheckResult;
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 repo_url: Option<String>,
pub profile_name: Option<String>,
pub debug: bool,
pub verify_stdin: Option<String>,
}
pub fn run(input: &VerifyInput) -> Result<VerifyReport> {
let root = &input.root;
let mut report = VerifyReport::default();
for check in discovery::run(root, &input.repo_url, &input.profile_name)? {
report.push(check);
}
let skill_files = discovery::find_skill_files(root);
let mut spawned_primary = false;
for skill_path in &skill_files {
let skill_md = match std::fs::read_to_string(skill_path) {
Ok(s) => s,
Err(e) => {
report.push(CheckResult::warn(
"invocation.read_failed",
"skills a verify can spawn should be readable",
format!("{}: read failed ({}); invocation drift check skipped for this skill", discovery::rel_unix(root, skill_path), e),
"To fix: check file permissions, ensure UTF-8 encoding (no Latin-1), and re-run.",
));
continue;
}
};
let is_cli = invocation::extract_documented_invocation(&skill_md).is_some();
let cmd = if !is_cli {
None
} else if !spawned_primary {
spawned_primary = true;
input.cli_command.clone()
} else {
match invocation::command_from_documented(&skill_md) {
Some(c) if crate::introspect::which_on_path(&c[0]).is_some() => Some(c),
Some(c) => {
report.push(CheckResult::warn(
"invocation.secondary_not_runnable",
"every documented CLI can be spawned for drift checks",
format!("secondary skill documents CLI `{}`, which is not on PATH; its drift checks were skipped", c[0]),
"To fix: install/build the secondary CLI so it is on PATH, then re-run verify.",
));
continue;
}
None => {
report.push(CheckResult::warn(
"invocation.secondary_unparseable",
"every documented CLI can be spawned for drift checks",
format!("could not derive a command from {}'s documented invocation; its drift checks were skipped", discovery::rel_unix(root, skill_path)),
"To fix: document the CLI with a plain command line in the `## Invocation` section.",
));
continue;
}
}
};
let inv = InvocationInput::new(
root,
&input.spawn_root,
&skill_md,
cmd.as_deref(),
input.debug,
input.verify_stdin.as_deref(),
);
invocation::run(&inv, &mut report)?;
}
Ok(report)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
Human,
Json,
Sarif,
}
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"));
}
}
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")
}
pub fn render_sarif(report: &VerifyReport) -> String {
use self::result::Severity;
let results: Vec<_> = report
.results
.iter()
.filter(|r| matches!(r.severity, Severity::Warn | Severity::Error))
.map(|r| {
let level = match r.severity {
Severity::Warn => "warning",
Severity::Error => "error",
_ => "none",
};
let mut result = serde_json::json!({
"ruleId": r.check_id,
"level": level,
"message": { "text": r.message },
});
if let Some(s) = &r.suggestion {
result["message"]["text"] =
serde_json::Value::String(format!("{}\nSuggestion: {s}", r.message));
}
if let Some((file, line)) = &r.location {
let mut region = serde_json::Map::new();
if let Some(n) = line {
region.insert("startLine".to_string(), serde_json::Value::from(*n));
}
let mut phys_loc = serde_json::json!({
"artifactLocation": { "uri": file }
});
if !region.is_empty() {
phys_loc["region"] = serde_json::Value::Object(region);
}
result["locations"] = serde_json::json!([phys_loc]);
}
result
})
.collect();
let body = serde_json::json!({
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "skillpack",
"informationUri": "https://github.com/nordicnode/skillpack"
}
},
"results": results
}]
});
serde_json::to_string_pretty(&body).expect("verify report serializes to SARIF JSON")
}