Skip to main content

a3s_code_core/
verification.rs

1//! Verification contracts for A3S Code 2.0.
2//!
3//! Verification is represented as structured checks and reports. The first
4//! stage is intentionally conservative: required checks start as
5//! `needs_review` until a verifier or the harness marks them passed/failed.
6
7use crate::program::ProgramVerificationHint;
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12pub const VERIFICATION_REPORT_SCHEMA: &str = "a3s.verification_report.v1";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum VerificationStatus {
17    Passed,
18    Failed,
19    NeedsReview,
20    Skipped,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct VerificationCheck {
25    pub id: String,
26    pub kind: String,
27    pub description: String,
28    pub status: VerificationStatus,
29    #[serde(default)]
30    pub required: bool,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub suggested_tools: Vec<String>,
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub evidence_uris: Vec<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub residual_risk: Option<String>,
37}
38
39impl VerificationCheck {
40    pub fn required(
41        id: impl Into<String>,
42        kind: impl Into<String>,
43        description: impl Into<String>,
44    ) -> Self {
45        Self {
46            id: id.into(),
47            kind: kind.into(),
48            description: description.into(),
49            status: VerificationStatus::NeedsReview,
50            required: true,
51            suggested_tools: Vec::new(),
52            evidence_uris: Vec::new(),
53            residual_risk: None,
54        }
55    }
56
57    pub fn optional(
58        id: impl Into<String>,
59        kind: impl Into<String>,
60        description: impl Into<String>,
61    ) -> Self {
62        Self {
63            required: false,
64            ..Self::required(id, kind, description)
65        }
66    }
67
68    pub fn with_status(mut self, status: VerificationStatus) -> Self {
69        self.status = status;
70        self
71    }
72
73    pub fn with_suggested_tools(
74        mut self,
75        tools: impl IntoIterator<Item = impl Into<String>>,
76    ) -> Self {
77        self.suggested_tools = tools.into_iter().map(Into::into).collect();
78        self
79    }
80
81    pub fn with_evidence_uris(mut self, uris: impl IntoIterator<Item = impl Into<String>>) -> Self {
82        self.evidence_uris = uris.into_iter().map(Into::into).collect();
83        self
84    }
85
86    pub fn with_residual_risk(mut self, risk: impl Into<String>) -> Self {
87        self.residual_risk = Some(risk.into());
88        self
89    }
90
91    pub fn from_program_hint(subject: &str, index: usize, hint: &ProgramVerificationHint) -> Self {
92        let id = format!("program:{subject}:{}:{index}", hint.kind);
93        let check = if hint.required {
94            Self::required(id, hint.kind.clone(), hint.message.clone())
95        } else {
96            Self::optional(id, hint.kind.clone(), hint.message.clone())
97        };
98
99        check
100            .with_suggested_tools(hint.suggested_tools.clone())
101            .with_evidence_uris(hint.evidence_uris.clone())
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct VerificationCommand {
107    pub id: String,
108    pub kind: String,
109    pub description: String,
110    pub command: String,
111    #[serde(default)]
112    pub required: bool,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub timeout_ms: Option<u64>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct VerificationPreset {
119    pub id: String,
120    pub project_kind: String,
121    pub description: String,
122    pub commands: Vec<VerificationCommand>,
123}
124
125impl VerificationPreset {
126    pub fn new(
127        id: impl Into<String>,
128        project_kind: impl Into<String>,
129        description: impl Into<String>,
130        commands: Vec<VerificationCommand>,
131    ) -> Self {
132        Self {
133            id: id.into(),
134            project_kind: project_kind.into(),
135            description: description.into(),
136            commands,
137        }
138    }
139}
140
141impl VerificationCommand {
142    pub fn required(
143        id: impl Into<String>,
144        kind: impl Into<String>,
145        description: impl Into<String>,
146        command: impl Into<String>,
147    ) -> Self {
148        Self {
149            id: id.into(),
150            kind: kind.into(),
151            description: description.into(),
152            command: command.into(),
153            required: true,
154            timeout_ms: None,
155        }
156    }
157
158    pub fn optional(
159        id: impl Into<String>,
160        kind: impl Into<String>,
161        description: impl Into<String>,
162        command: impl Into<String>,
163    ) -> Self {
164        Self {
165            required: false,
166            ..Self::required(id, kind, description, command)
167        }
168    }
169
170    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
171        self.timeout_ms = Some(timeout_ms);
172        self
173    }
174
175    pub fn to_check(&self) -> VerificationCheck {
176        let check = if self.required {
177            VerificationCheck::required(
178                self.id.clone(),
179                self.kind.clone(),
180                self.description.clone(),
181            )
182        } else {
183            VerificationCheck::optional(
184                self.id.clone(),
185                self.kind.clone(),
186                self.description.clone(),
187            )
188        };
189
190        check.with_suggested_tools(["bash"])
191    }
192
193    pub fn check_from_execution(
194        &self,
195        exit_code: i32,
196        metadata: Option<&serde_json::Value>,
197        execution_error: Option<&str>,
198    ) -> VerificationCheck {
199        let mut check =
200            self.to_check()
201                .with_status(if exit_code == 0 && execution_error.is_none() {
202                    VerificationStatus::Passed
203                } else {
204                    VerificationStatus::Failed
205                });
206
207        let evidence_uris = artifact_uris(metadata);
208        if !evidence_uris.is_empty() {
209            check = check.with_evidence_uris(evidence_uris);
210        }
211
212        if let Some(error) = execution_error {
213            return check
214                .with_residual_risk(format!("verification command could not run: {error}"));
215        }
216
217        if exit_code != 0 {
218            check = check.with_residual_risk(format!(
219                "verification command exited with code {exit_code}: {}",
220                self.command
221            ));
222        }
223
224        check
225    }
226}
227
228pub fn verification_presets_for_workspace(workspace: impl AsRef<Path>) -> Vec<VerificationPreset> {
229    let workspace = workspace.as_ref();
230    let mut presets = Vec::new();
231
232    if workspace.join("Cargo.toml").is_file() {
233        presets.push(VerificationPreset::new(
234            "rust-default",
235            "rust",
236            "Rust cargo verification",
237            vec![
238                VerificationCommand::required(
239                    "rust:fmt",
240                    "format",
241                    "Check Rust formatting",
242                    "cargo fmt -- --check",
243                ),
244                VerificationCommand::required(
245                    "rust:check",
246                    "type_check",
247                    "Run Rust type checking",
248                    "cargo check",
249                ),
250                VerificationCommand::required("rust:test", "test", "Run Rust tests", "cargo test"),
251                VerificationCommand::optional(
252                    "rust:clippy",
253                    "lint",
254                    "Run Rust clippy lints",
255                    "cargo clippy -- -D warnings",
256                ),
257            ],
258        ));
259    }
260
261    if workspace.join("package.json").is_file() {
262        if let Some(preset) = node_verification_preset(workspace) {
263            presets.push(preset);
264        }
265    }
266
267    if workspace.join("pyproject.toml").is_file() || workspace.join("pytest.ini").is_file() {
268        let mut commands = Vec::new();
269        if workspace.join("tests").is_dir()
270            || file_contains(&workspace.join("pyproject.toml"), "[tool.pytest")
271            || workspace.join("pytest.ini").is_file()
272        {
273            commands.push(VerificationCommand::required(
274                "python:test",
275                "test",
276                "Run Python tests",
277                "python -m pytest",
278            ));
279        }
280        if workspace.join("ruff.toml").is_file()
281            || workspace.join(".ruff.toml").is_file()
282            || file_contains(&workspace.join("pyproject.toml"), "[tool.ruff")
283        {
284            commands.push(VerificationCommand::optional(
285                "python:ruff",
286                "lint",
287                "Run Ruff lint checks",
288                "python -m ruff check .",
289            ));
290        }
291        if workspace.join("mypy.ini").is_file()
292            || workspace.join(".mypy.ini").is_file()
293            || file_contains(&workspace.join("pyproject.toml"), "[tool.mypy")
294        {
295            commands.push(VerificationCommand::optional(
296                "python:mypy",
297                "type_check",
298                "Run mypy type checking",
299                "python -m mypy .",
300            ));
301        }
302        if !commands.is_empty() {
303            presets.push(VerificationPreset::new(
304                "python-default",
305                "python",
306                "Python project verification",
307                commands,
308            ));
309        }
310    }
311
312    if workspace.join("go.mod").is_file() {
313        presets.push(VerificationPreset::new(
314            "go-default",
315            "go",
316            "Go module verification",
317            vec![
318                VerificationCommand::required("go:test", "test", "Run Go tests", "go test ./..."),
319                VerificationCommand::optional("go:vet", "lint", "Run go vet", "go vet ./..."),
320            ],
321        ));
322    }
323
324    presets
325}
326
327fn node_verification_preset(workspace: &Path) -> Option<VerificationPreset> {
328    let package_json = std::fs::read_to_string(workspace.join("package.json")).ok()?;
329    let package: serde_json::Value = serde_json::from_str(&package_json).ok()?;
330    let scripts = package.get("scripts").and_then(|value| value.as_object())?;
331    let package_manager = detect_node_package_manager(workspace, &package);
332    let mut commands = Vec::new();
333
334    for (script, kind, description, required) in [
335        ("test", "test", "Run JavaScript tests", true),
336        (
337            "typecheck",
338            "type_check",
339            "Run JavaScript type checks",
340            false,
341        ),
342        ("lint", "lint", "Run JavaScript lint checks", false),
343    ] {
344        if scripts.contains_key(script) {
345            let command = node_script_command(&package_manager, script);
346            let id = format!("node:{script}");
347            let verification = if required {
348                VerificationCommand::required(id, kind, description, command)
349            } else {
350                VerificationCommand::optional(id, kind, description, command)
351            };
352            commands.push(verification);
353        }
354    }
355
356    if commands.is_empty() {
357        return None;
358    }
359
360    Some(VerificationPreset::new(
361        "node-default",
362        "node",
363        "Node.js package verification",
364        commands,
365    ))
366}
367
368fn detect_node_package_manager(workspace: &Path, package: &serde_json::Value) -> String {
369    if let Some(manager) = package
370        .get("packageManager")
371        .and_then(|value| value.as_str())
372    {
373        if let Some((name, _)) = manager.split_once('@') {
374            return name.to_string();
375        }
376    }
377
378    if workspace.join("pnpm-lock.yaml").is_file() {
379        "pnpm".to_string()
380    } else if workspace.join("yarn.lock").is_file() {
381        "yarn".to_string()
382    } else if workspace.join("bun.lockb").is_file() || workspace.join("bun.lock").is_file() {
383        "bun".to_string()
384    } else {
385        "npm".to_string()
386    }
387}
388
389fn node_script_command(package_manager: &str, script: &str) -> String {
390    match package_manager {
391        "pnpm" | "yarn" => format!("{package_manager} {script}"),
392        "bun" => format!("bun run {script}"),
393        "npm" if script == "test" => "npm test".to_string(),
394        "npm" => format!("npm run {script}"),
395        other => format!("{other} run {script}"),
396    }
397}
398
399fn file_contains(path: &Path, needle: &str) -> bool {
400    std::fs::read_to_string(path)
401        .map(|content| content.contains(needle))
402        .unwrap_or(false)
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct VerificationReport {
407    pub schema: String,
408    pub subject: String,
409    pub status: VerificationStatus,
410    pub checks: Vec<VerificationCheck>,
411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
412    pub residual_risks: Vec<String>,
413}
414
415impl VerificationReport {
416    pub fn new(subject: impl Into<String>, checks: Vec<VerificationCheck>) -> Self {
417        let mut report = Self {
418            schema: VERIFICATION_REPORT_SCHEMA.to_string(),
419            subject: subject.into(),
420            status: VerificationStatus::Skipped,
421            checks,
422            residual_risks: Vec::new(),
423        };
424        report.status = report.derive_status();
425        report
426    }
427
428    pub fn from_program_hints(subject: &str, hints: &[ProgramVerificationHint]) -> Self {
429        let checks = hints
430            .iter()
431            .enumerate()
432            .map(|(index, hint)| VerificationCheck::from_program_hint(subject, index, hint))
433            .collect();
434        Self::new(format!("program:{subject}"), checks)
435    }
436
437    pub fn with_residual_risk(mut self, risk: impl Into<String>) -> Self {
438        self.residual_risks.push(risk.into());
439        self.status = self.derive_status();
440        self
441    }
442
443    pub fn is_complete(&self) -> bool {
444        !matches!(self.status, VerificationStatus::NeedsReview)
445    }
446
447    pub fn to_value(&self) -> serde_json::Value {
448        serde_json::to_value(self).unwrap_or_else(|_| {
449            serde_json::json!({
450                "schema": VERIFICATION_REPORT_SCHEMA,
451                "subject": self.subject,
452                "status": "failed",
453                "checks": [],
454                "residual_risks": ["failed to serialize verification report"],
455            })
456        })
457    }
458
459    fn derive_status(&self) -> VerificationStatus {
460        if self
461            .checks
462            .iter()
463            .any(|check| check.status == VerificationStatus::Failed)
464        {
465            return VerificationStatus::Failed;
466        }
467
468        if self.checks.iter().any(|check| {
469            check.required
470                && matches!(
471                    check.status,
472                    VerificationStatus::NeedsReview | VerificationStatus::Skipped
473                )
474        }) {
475            return VerificationStatus::NeedsReview;
476        }
477
478        if !self.residual_risks.is_empty() {
479            return VerificationStatus::NeedsReview;
480        }
481
482        if self.checks.is_empty() {
483            VerificationStatus::Skipped
484        } else {
485            VerificationStatus::Passed
486        }
487    }
488}
489
490fn artifact_uris(metadata: Option<&serde_json::Value>) -> Vec<String> {
491    let mut uris = Vec::new();
492    if let Some(metadata) = metadata {
493        collect_artifact_uris(metadata, &mut uris);
494    }
495    uris.sort();
496    uris.dedup();
497    uris
498}
499
500fn collect_artifact_uris(value: &serde_json::Value, uris: &mut Vec<String>) {
501    match value {
502        serde_json::Value::Object(object) => {
503            if let Some(uri) = object.get("artifact_uri").and_then(|value| value.as_str()) {
504                uris.push(uri.to_string());
505            }
506            for value in object.values() {
507                collect_artifact_uris(value, uris);
508            }
509        }
510        serde_json::Value::Array(items) => {
511            for value in items {
512                collect_artifact_uris(value, uris);
513            }
514        }
515        _ => {}
516    }
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct VerificationSummary {
521    pub status: VerificationStatus,
522    pub report_count: usize,
523    pub required_check_count: usize,
524    pub pending_required_check_count: usize,
525    pub failed_check_count: usize,
526    pub residual_risk_count: usize,
527    #[serde(default, skip_serializing_if = "Vec::is_empty")]
528    pub pending_subjects: Vec<String>,
529    #[serde(default, skip_serializing_if = "Vec::is_empty")]
530    pub failed_subjects: Vec<String>,
531}
532
533impl VerificationSummary {
534    pub fn from_reports(reports: &[VerificationReport]) -> Self {
535        let mut required_check_count = 0;
536        let mut pending_required_check_count = 0;
537        let mut failed_check_count = 0;
538        let mut residual_risk_count = 0;
539        let mut pending_subjects = Vec::new();
540        let mut failed_subjects = Vec::new();
541
542        for report in reports {
543            if matches!(report.status, VerificationStatus::NeedsReview) {
544                pending_subjects.push(report.subject.clone());
545            }
546
547            if matches!(report.status, VerificationStatus::Failed) {
548                failed_subjects.push(report.subject.clone());
549            }
550
551            residual_risk_count += report.residual_risks.len();
552
553            for check in &report.checks {
554                if check.required {
555                    required_check_count += 1;
556                    if matches!(
557                        check.status,
558                        VerificationStatus::NeedsReview | VerificationStatus::Skipped
559                    ) {
560                        pending_required_check_count += 1;
561                        pending_subjects.push(report.subject.clone());
562                    }
563                }
564
565                if check.status == VerificationStatus::Failed {
566                    failed_check_count += 1;
567                    failed_subjects.push(report.subject.clone());
568                }
569
570                if check.residual_risk.is_some() {
571                    residual_risk_count += 1;
572                    pending_subjects.push(report.subject.clone());
573                }
574            }
575        }
576
577        pending_subjects.sort();
578        pending_subjects.dedup();
579        failed_subjects.sort();
580        failed_subjects.dedup();
581
582        let status = if failed_check_count > 0
583            || reports
584                .iter()
585                .any(|report| report.status == VerificationStatus::Failed)
586        {
587            VerificationStatus::Failed
588        } else if pending_required_check_count > 0
589            || residual_risk_count > 0
590            || reports
591                .iter()
592                .any(|report| report.status == VerificationStatus::NeedsReview)
593        {
594            VerificationStatus::NeedsReview
595        } else if reports.is_empty() {
596            VerificationStatus::Skipped
597        } else {
598            VerificationStatus::Passed
599        };
600
601        Self {
602            status,
603            report_count: reports.len(),
604            required_check_count,
605            pending_required_check_count,
606            failed_check_count,
607            residual_risk_count,
608            pending_subjects,
609            failed_subjects,
610        }
611    }
612
613    pub fn is_complete(&self) -> bool {
614        !matches!(self.status, VerificationStatus::NeedsReview)
615    }
616
617    pub fn to_value(&self) -> serde_json::Value {
618        serde_json::to_value(self).unwrap_or_else(|_| {
619            serde_json::json!({
620                "status": "failed",
621                "report_count": self.report_count,
622                "required_check_count": self.required_check_count,
623                "pending_required_check_count": self.pending_required_check_count,
624                "failed_check_count": self.failed_check_count,
625                "residual_risk_count": self.residual_risk_count,
626                "failed_subjects": ["failed to serialize verification summary"],
627            })
628        })
629    }
630}
631
632pub fn format_verification_summary(summary: &VerificationSummary) -> String {
633    let reports = plural(summary.report_count, "report", "reports");
634    let required_checks = plural(
635        summary.required_check_count,
636        "required check",
637        "required checks",
638    );
639
640    let mut text = match summary.status {
641        VerificationStatus::Skipped if summary.report_count == 0 => {
642            "Verification skipped: no reports.".to_string()
643        }
644        VerificationStatus::Skipped => format!("Verification skipped: {reports}."),
645        VerificationStatus::Passed => {
646            format!("Verification passed: {reports}, {required_checks}.")
647        }
648        VerificationStatus::Failed => {
649            let failed = if summary.failed_check_count > 0 {
650                plural(summary.failed_check_count, "failed check", "failed checks")
651            } else {
652                "failed report".to_string()
653            };
654            let subjects = subject_list(&summary.failed_subjects);
655            if subjects.is_empty() {
656                format!("Verification failed: {failed}. {reports}, {required_checks}.")
657            } else {
658                format!(
659                    "Verification failed: {failed} across subjects: {subjects}. {reports}, {required_checks}."
660                )
661            }
662        }
663        VerificationStatus::NeedsReview => {
664            let pending = if summary.pending_required_check_count > 0 {
665                plural(
666                    summary.pending_required_check_count,
667                    "pending required check",
668                    "pending required checks",
669                )
670            } else {
671                "review required".to_string()
672            };
673            let subjects = subject_list(&summary.pending_subjects);
674            if subjects.is_empty() {
675                format!("Verification needs review: {pending}. {reports}, {required_checks}.")
676            } else {
677                format!(
678                    "Verification needs review: {pending} across subjects: {subjects}. {reports}, {required_checks}."
679                )
680            }
681        }
682    };
683
684    if summary.residual_risk_count > 0 {
685        text.push(' ');
686        text.push_str(&format!("Residual risks: {}.", summary.residual_risk_count));
687    }
688
689    text
690}
691
692pub fn verification_status_label(status: VerificationStatus) -> &'static str {
693    match status {
694        VerificationStatus::Passed => "passed",
695        VerificationStatus::Failed => "failed",
696        VerificationStatus::NeedsReview => "needs_review",
697        VerificationStatus::Skipped => "skipped",
698    }
699}
700
701fn plural(count: usize, singular: &str, plural: &str) -> String {
702    if count == 1 {
703        format!("1 {singular}")
704    } else {
705        format!("{count} {plural}")
706    }
707}
708
709fn subject_list(subjects: &[String]) -> String {
710    const MAX_SUBJECTS: usize = 5;
711    let mut visible: Vec<&str> = subjects
712        .iter()
713        .take(MAX_SUBJECTS)
714        .map(String::as_str)
715        .collect();
716    if subjects.len() > MAX_SUBJECTS {
717        visible.push("...");
718    }
719    visible.join(", ")
720}
721
722pub trait Verifier: Send + Sync {
723    fn verify(&self, checks: Vec<VerificationCheck>) -> Result<VerificationReport>;
724}
725
726#[derive(Debug, Clone)]
727pub struct StaticVerifier {
728    subject: String,
729}
730
731impl StaticVerifier {
732    pub fn new(subject: impl Into<String>) -> Self {
733        Self {
734            subject: subject.into(),
735        }
736    }
737}
738
739impl Verifier for StaticVerifier {
740    fn verify(&self, checks: Vec<VerificationCheck>) -> Result<VerificationReport> {
741        Ok(VerificationReport::new(self.subject.clone(), checks))
742    }
743}
744
745#[cfg(test)]
746#[path = "verification/tests.rs"]
747mod tests;