use crate::program::ProgramVerificationHint;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const VERIFICATION_REPORT_SCHEMA: &str = "a3s.verification_report.v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerificationStatus {
Passed,
Failed,
NeedsReview,
Skipped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationCheck {
pub id: String,
pub kind: String,
pub description: String,
pub status: VerificationStatus,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggested_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence_uris: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub residual_risk: Option<String>,
}
impl VerificationCheck {
pub fn required(
id: impl Into<String>,
kind: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
id: id.into(),
kind: kind.into(),
description: description.into(),
status: VerificationStatus::NeedsReview,
required: true,
suggested_tools: Vec::new(),
evidence_uris: Vec::new(),
residual_risk: None,
}
}
pub fn optional(
id: impl Into<String>,
kind: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
required: false,
..Self::required(id, kind, description)
}
}
pub fn with_status(mut self, status: VerificationStatus) -> Self {
self.status = status;
self
}
pub fn with_suggested_tools(
mut self,
tools: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.suggested_tools = tools.into_iter().map(Into::into).collect();
self
}
pub fn with_evidence_uris(mut self, uris: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.evidence_uris = uris.into_iter().map(Into::into).collect();
self
}
pub fn with_residual_risk(mut self, risk: impl Into<String>) -> Self {
self.residual_risk = Some(risk.into());
self
}
pub fn from_program_hint(subject: &str, index: usize, hint: &ProgramVerificationHint) -> Self {
let id = format!("program:{subject}:{}:{index}", hint.kind);
let check = if hint.required {
Self::required(id, hint.kind.clone(), hint.message.clone())
} else {
Self::optional(id, hint.kind.clone(), hint.message.clone())
};
check
.with_suggested_tools(hint.suggested_tools.clone())
.with_evidence_uris(hint.evidence_uris.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationCommand {
pub id: String,
pub kind: String,
pub description: String,
pub command: String,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationPreset {
pub id: String,
pub project_kind: String,
pub description: String,
pub commands: Vec<VerificationCommand>,
}
impl VerificationPreset {
pub fn new(
id: impl Into<String>,
project_kind: impl Into<String>,
description: impl Into<String>,
commands: Vec<VerificationCommand>,
) -> Self {
Self {
id: id.into(),
project_kind: project_kind.into(),
description: description.into(),
commands,
}
}
}
impl VerificationCommand {
pub fn required(
id: impl Into<String>,
kind: impl Into<String>,
description: impl Into<String>,
command: impl Into<String>,
) -> Self {
Self {
id: id.into(),
kind: kind.into(),
description: description.into(),
command: command.into(),
required: true,
timeout_ms: None,
}
}
pub fn optional(
id: impl Into<String>,
kind: impl Into<String>,
description: impl Into<String>,
command: impl Into<String>,
) -> Self {
Self {
required: false,
..Self::required(id, kind, description, command)
}
}
pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
self.timeout_ms = Some(timeout_ms);
self
}
pub fn to_check(&self) -> VerificationCheck {
let check = if self.required {
VerificationCheck::required(
self.id.clone(),
self.kind.clone(),
self.description.clone(),
)
} else {
VerificationCheck::optional(
self.id.clone(),
self.kind.clone(),
self.description.clone(),
)
};
check.with_suggested_tools(["bash"])
}
pub fn check_from_execution(
&self,
exit_code: i32,
metadata: Option<&serde_json::Value>,
execution_error: Option<&str>,
) -> VerificationCheck {
let mut check =
self.to_check()
.with_status(if exit_code == 0 && execution_error.is_none() {
VerificationStatus::Passed
} else {
VerificationStatus::Failed
});
let evidence_uris = artifact_uris(metadata);
if !evidence_uris.is_empty() {
check = check.with_evidence_uris(evidence_uris);
}
if let Some(error) = execution_error {
return check
.with_residual_risk(format!("verification command could not run: {error}"));
}
if exit_code != 0 {
check = check.with_residual_risk(format!(
"verification command exited with code {exit_code}: {}",
self.command
));
}
check
}
}
pub fn verification_presets_for_workspace(workspace: impl AsRef<Path>) -> Vec<VerificationPreset> {
let workspace = workspace.as_ref();
let mut presets = Vec::new();
if workspace.join("Cargo.toml").is_file() {
presets.push(VerificationPreset::new(
"rust-default",
"rust",
"Rust cargo verification",
vec![
VerificationCommand::required(
"rust:fmt",
"format",
"Check Rust formatting",
"cargo fmt -- --check",
),
VerificationCommand::required(
"rust:check",
"type_check",
"Run Rust type checking",
"cargo check",
),
VerificationCommand::required("rust:test", "test", "Run Rust tests", "cargo test"),
VerificationCommand::optional(
"rust:clippy",
"lint",
"Run Rust clippy lints",
"cargo clippy -- -D warnings",
),
],
));
}
if workspace.join("package.json").is_file() {
if let Some(preset) = node_verification_preset(workspace) {
presets.push(preset);
}
}
if workspace.join("pyproject.toml").is_file() || workspace.join("pytest.ini").is_file() {
let mut commands = Vec::new();
if workspace.join("tests").is_dir()
|| file_contains(&workspace.join("pyproject.toml"), "[tool.pytest")
|| workspace.join("pytest.ini").is_file()
{
commands.push(VerificationCommand::required(
"python:test",
"test",
"Run Python tests",
"python -m pytest",
));
}
if workspace.join("ruff.toml").is_file()
|| workspace.join(".ruff.toml").is_file()
|| file_contains(&workspace.join("pyproject.toml"), "[tool.ruff")
{
commands.push(VerificationCommand::optional(
"python:ruff",
"lint",
"Run Ruff lint checks",
"python -m ruff check .",
));
}
if workspace.join("mypy.ini").is_file()
|| workspace.join(".mypy.ini").is_file()
|| file_contains(&workspace.join("pyproject.toml"), "[tool.mypy")
{
commands.push(VerificationCommand::optional(
"python:mypy",
"type_check",
"Run mypy type checking",
"python -m mypy .",
));
}
if !commands.is_empty() {
presets.push(VerificationPreset::new(
"python-default",
"python",
"Python project verification",
commands,
));
}
}
if workspace.join("go.mod").is_file() {
presets.push(VerificationPreset::new(
"go-default",
"go",
"Go module verification",
vec![
VerificationCommand::required("go:test", "test", "Run Go tests", "go test ./..."),
VerificationCommand::optional("go:vet", "lint", "Run go vet", "go vet ./..."),
],
));
}
presets
}
fn node_verification_preset(workspace: &Path) -> Option<VerificationPreset> {
let package_json = std::fs::read_to_string(workspace.join("package.json")).ok()?;
let package: serde_json::Value = serde_json::from_str(&package_json).ok()?;
let scripts = package.get("scripts").and_then(|value| value.as_object())?;
let package_manager = detect_node_package_manager(workspace, &package);
let mut commands = Vec::new();
for (script, kind, description, required) in [
("test", "test", "Run JavaScript tests", true),
(
"typecheck",
"type_check",
"Run JavaScript type checks",
false,
),
("lint", "lint", "Run JavaScript lint checks", false),
] {
if scripts.contains_key(script) {
let command = node_script_command(&package_manager, script);
let id = format!("node:{script}");
let verification = if required {
VerificationCommand::required(id, kind, description, command)
} else {
VerificationCommand::optional(id, kind, description, command)
};
commands.push(verification);
}
}
if commands.is_empty() {
return None;
}
Some(VerificationPreset::new(
"node-default",
"node",
"Node.js package verification",
commands,
))
}
fn detect_node_package_manager(workspace: &Path, package: &serde_json::Value) -> String {
if let Some(manager) = package
.get("packageManager")
.and_then(|value| value.as_str())
{
if let Some((name, _)) = manager.split_once('@') {
return name.to_string();
}
}
if workspace.join("pnpm-lock.yaml").is_file() {
"pnpm".to_string()
} else if workspace.join("yarn.lock").is_file() {
"yarn".to_string()
} else if workspace.join("bun.lockb").is_file() || workspace.join("bun.lock").is_file() {
"bun".to_string()
} else {
"npm".to_string()
}
}
fn node_script_command(package_manager: &str, script: &str) -> String {
match package_manager {
"pnpm" | "yarn" => format!("{package_manager} {script}"),
"bun" => format!("bun run {script}"),
"npm" if script == "test" => "npm test".to_string(),
"npm" => format!("npm run {script}"),
other => format!("{other} run {script}"),
}
}
fn file_contains(path: &Path, needle: &str) -> bool {
std::fs::read_to_string(path)
.map(|content| content.contains(needle))
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationReport {
pub schema: String,
pub subject: String,
pub status: VerificationStatus,
pub checks: Vec<VerificationCheck>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub residual_risks: Vec<String>,
}
impl VerificationReport {
pub fn new(subject: impl Into<String>, checks: Vec<VerificationCheck>) -> Self {
let mut report = Self {
schema: VERIFICATION_REPORT_SCHEMA.to_string(),
subject: subject.into(),
status: VerificationStatus::Skipped,
checks,
residual_risks: Vec::new(),
};
report.status = report.derive_status();
report
}
pub fn from_program_hints(subject: &str, hints: &[ProgramVerificationHint]) -> Self {
let checks = hints
.iter()
.enumerate()
.map(|(index, hint)| VerificationCheck::from_program_hint(subject, index, hint))
.collect();
Self::new(format!("program:{subject}"), checks)
}
pub fn with_residual_risk(mut self, risk: impl Into<String>) -> Self {
self.residual_risks.push(risk.into());
self.status = self.derive_status();
self
}
pub fn is_complete(&self) -> bool {
!matches!(self.status, VerificationStatus::NeedsReview)
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_else(|_| {
serde_json::json!({
"schema": VERIFICATION_REPORT_SCHEMA,
"subject": self.subject,
"status": "failed",
"checks": [],
"residual_risks": ["failed to serialize verification report"],
})
})
}
fn derive_status(&self) -> VerificationStatus {
if self
.checks
.iter()
.any(|check| check.status == VerificationStatus::Failed)
{
return VerificationStatus::Failed;
}
if self.checks.iter().any(|check| {
check.required
&& matches!(
check.status,
VerificationStatus::NeedsReview | VerificationStatus::Skipped
)
}) {
return VerificationStatus::NeedsReview;
}
if !self.residual_risks.is_empty() {
return VerificationStatus::NeedsReview;
}
if self.checks.is_empty() {
VerificationStatus::Skipped
} else {
VerificationStatus::Passed
}
}
}
fn artifact_uris(metadata: Option<&serde_json::Value>) -> Vec<String> {
let mut uris = Vec::new();
if let Some(metadata) = metadata {
collect_artifact_uris(metadata, &mut uris);
}
uris.sort();
uris.dedup();
uris
}
fn collect_artifact_uris(value: &serde_json::Value, uris: &mut Vec<String>) {
match value {
serde_json::Value::Object(object) => {
if let Some(uri) = object.get("artifact_uri").and_then(|value| value.as_str()) {
uris.push(uri.to_string());
}
for value in object.values() {
collect_artifact_uris(value, uris);
}
}
serde_json::Value::Array(items) => {
for value in items {
collect_artifact_uris(value, uris);
}
}
_ => {}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationSummary {
pub status: VerificationStatus,
pub report_count: usize,
pub required_check_count: usize,
pub pending_required_check_count: usize,
pub failed_check_count: usize,
pub residual_risk_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending_subjects: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failed_subjects: Vec<String>,
}
impl VerificationSummary {
pub fn from_reports(reports: &[VerificationReport]) -> Self {
let mut required_check_count = 0;
let mut pending_required_check_count = 0;
let mut failed_check_count = 0;
let mut residual_risk_count = 0;
let mut pending_subjects = Vec::new();
let mut failed_subjects = Vec::new();
for report in reports {
if matches!(report.status, VerificationStatus::NeedsReview) {
pending_subjects.push(report.subject.clone());
}
if matches!(report.status, VerificationStatus::Failed) {
failed_subjects.push(report.subject.clone());
}
residual_risk_count += report.residual_risks.len();
for check in &report.checks {
if check.required {
required_check_count += 1;
if matches!(
check.status,
VerificationStatus::NeedsReview | VerificationStatus::Skipped
) {
pending_required_check_count += 1;
pending_subjects.push(report.subject.clone());
}
}
if check.status == VerificationStatus::Failed {
failed_check_count += 1;
failed_subjects.push(report.subject.clone());
}
if check.residual_risk.is_some() {
residual_risk_count += 1;
pending_subjects.push(report.subject.clone());
}
}
}
pending_subjects.sort();
pending_subjects.dedup();
failed_subjects.sort();
failed_subjects.dedup();
let status = if failed_check_count > 0
|| reports
.iter()
.any(|report| report.status == VerificationStatus::Failed)
{
VerificationStatus::Failed
} else if pending_required_check_count > 0
|| residual_risk_count > 0
|| reports
.iter()
.any(|report| report.status == VerificationStatus::NeedsReview)
{
VerificationStatus::NeedsReview
} else if reports.is_empty() {
VerificationStatus::Skipped
} else {
VerificationStatus::Passed
};
Self {
status,
report_count: reports.len(),
required_check_count,
pending_required_check_count,
failed_check_count,
residual_risk_count,
pending_subjects,
failed_subjects,
}
}
pub fn is_complete(&self) -> bool {
!matches!(self.status, VerificationStatus::NeedsReview)
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_else(|_| {
serde_json::json!({
"status": "failed",
"report_count": self.report_count,
"required_check_count": self.required_check_count,
"pending_required_check_count": self.pending_required_check_count,
"failed_check_count": self.failed_check_count,
"residual_risk_count": self.residual_risk_count,
"failed_subjects": ["failed to serialize verification summary"],
})
})
}
}
pub fn format_verification_summary(summary: &VerificationSummary) -> String {
let reports = plural(summary.report_count, "report", "reports");
let required_checks = plural(
summary.required_check_count,
"required check",
"required checks",
);
let mut text = match summary.status {
VerificationStatus::Skipped if summary.report_count == 0 => {
"Verification skipped: no reports.".to_string()
}
VerificationStatus::Skipped => format!("Verification skipped: {reports}."),
VerificationStatus::Passed => {
format!("Verification passed: {reports}, {required_checks}.")
}
VerificationStatus::Failed => {
let failed = if summary.failed_check_count > 0 {
plural(summary.failed_check_count, "failed check", "failed checks")
} else {
"failed report".to_string()
};
let subjects = subject_list(&summary.failed_subjects);
if subjects.is_empty() {
format!("Verification failed: {failed}. {reports}, {required_checks}.")
} else {
format!(
"Verification failed: {failed} across subjects: {subjects}. {reports}, {required_checks}."
)
}
}
VerificationStatus::NeedsReview => {
let pending = if summary.pending_required_check_count > 0 {
plural(
summary.pending_required_check_count,
"pending required check",
"pending required checks",
)
} else {
"review required".to_string()
};
let subjects = subject_list(&summary.pending_subjects);
if subjects.is_empty() {
format!("Verification needs review: {pending}. {reports}, {required_checks}.")
} else {
format!(
"Verification needs review: {pending} across subjects: {subjects}. {reports}, {required_checks}."
)
}
}
};
if summary.residual_risk_count > 0 {
text.push(' ');
text.push_str(&format!("Residual risks: {}.", summary.residual_risk_count));
}
text
}
pub fn verification_status_label(status: VerificationStatus) -> &'static str {
match status {
VerificationStatus::Passed => "passed",
VerificationStatus::Failed => "failed",
VerificationStatus::NeedsReview => "needs_review",
VerificationStatus::Skipped => "skipped",
}
}
fn plural(count: usize, singular: &str, plural: &str) -> String {
if count == 1 {
format!("1 {singular}")
} else {
format!("{count} {plural}")
}
}
fn subject_list(subjects: &[String]) -> String {
const MAX_SUBJECTS: usize = 5;
let mut visible: Vec<&str> = subjects
.iter()
.take(MAX_SUBJECTS)
.map(String::as_str)
.collect();
if subjects.len() > MAX_SUBJECTS {
visible.push("...");
}
visible.join(", ")
}
pub trait Verifier: Send + Sync {
fn verify(&self, checks: Vec<VerificationCheck>) -> Result<VerificationReport>;
}
#[derive(Debug, Clone)]
pub struct StaticVerifier {
subject: String,
}
impl StaticVerifier {
pub fn new(subject: impl Into<String>) -> Self {
Self {
subject: subject.into(),
}
}
}
impl Verifier for StaticVerifier {
fn verify(&self, checks: Vec<VerificationCheck>) -> Result<VerificationReport> {
Ok(VerificationReport::new(self.subject.clone(), checks))
}
}
#[cfg(test)]
#[path = "verification/tests.rs"]
mod tests;