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)]
746mod tests {
747    use super::*;
748
749    #[test]
750    fn report_from_required_program_hint_needs_review() {
751        let hints = vec![
752            ProgramVerificationHint::new("inspect_matches", "Review matched files")
753                .required()
754                .with_suggested_tools(["read", "grep"])
755                .with_evidence_uris(["a3s://tool-output/grep/abc"]),
756        ];
757
758        let report = VerificationReport::from_program_hints("program_code_search", &hints);
759
760        assert_eq!(report.schema, VERIFICATION_REPORT_SCHEMA);
761        assert_eq!(report.subject, "program:program_code_search");
762        assert_eq!(report.status, VerificationStatus::NeedsReview);
763        assert!(!report.is_complete());
764        assert_eq!(report.checks[0].kind, "inspect_matches");
765        assert_eq!(report.checks[0].suggested_tools, vec!["read", "grep"]);
766        assert_eq!(
767            report.checks[0].evidence_uris,
768            vec!["a3s://tool-output/grep/abc"]
769        );
770    }
771
772    #[test]
773    fn report_passes_when_required_checks_pass() {
774        let check = VerificationCheck::required("check:build", "run_build", "Run build")
775            .with_status(VerificationStatus::Passed);
776
777        let report = VerificationReport::new("turn", vec![check]);
778
779        assert_eq!(report.status, VerificationStatus::Passed);
780        assert!(report.is_complete());
781    }
782
783    #[test]
784    fn report_fails_when_any_check_fails() {
785        let check = VerificationCheck::required("check:test", "run_tests", "Run tests")
786            .with_status(VerificationStatus::Failed);
787
788        let report = VerificationReport::new("turn", vec![check]);
789
790        assert_eq!(report.status, VerificationStatus::Failed);
791        assert!(report.is_complete());
792    }
793
794    #[test]
795    fn static_verifier_builds_report() {
796        let verifier = StaticVerifier::new("turn");
797        let check = VerificationCheck::optional("check:review", "review", "Review diff")
798            .with_status(VerificationStatus::Passed);
799
800        let report = verifier.verify(vec![check]).unwrap();
801
802        assert_eq!(report.subject, "turn");
803        assert_eq!(report.status, VerificationStatus::Passed);
804    }
805
806    #[test]
807    fn verification_command_builds_passed_check_with_evidence() {
808        let command = VerificationCommand::required(
809            "check:build",
810            "type_check",
811            "Run cargo check",
812            "cargo check",
813        );
814
815        let check = command.check_from_execution(
816            0,
817            Some(&serde_json::json!({
818                "artifact": {
819                    "artifact_uri": "a3s://tool-output/bash/abc"
820                }
821            })),
822            None,
823        );
824
825        assert_eq!(check.status, VerificationStatus::Passed);
826        assert!(check.required);
827        assert_eq!(check.suggested_tools, vec!["bash"]);
828        assert_eq!(check.evidence_uris, vec!["a3s://tool-output/bash/abc"]);
829        assert!(check.residual_risk.is_none());
830    }
831
832    #[test]
833    fn verification_command_builds_failed_check_from_exit_code() {
834        let command =
835            VerificationCommand::required("check:test", "test", "Run test suite", "cargo test");
836
837        let check = command.check_from_execution(101, None, None);
838
839        assert_eq!(check.status, VerificationStatus::Failed);
840        assert_eq!(
841            check.residual_risk.as_deref(),
842            Some("verification command exited with code 101: cargo test")
843        );
844    }
845
846    #[test]
847    fn rust_workspace_preset_uses_cargo_commands() {
848        let dir = tempfile::tempdir().unwrap();
849        std::fs::write(
850            dir.path().join("Cargo.toml"),
851            "[package]\nname = \"demo\"\n",
852        )
853        .unwrap();
854
855        let presets = verification_presets_for_workspace(dir.path());
856
857        assert_eq!(presets.len(), 1);
858        assert_eq!(presets[0].project_kind, "rust");
859        assert_eq!(presets[0].commands[0].command, "cargo fmt -- --check");
860        assert!(presets[0]
861            .commands
862            .iter()
863            .any(|command| command.command == "cargo test"));
864    }
865
866    #[test]
867    fn node_workspace_preset_uses_declared_scripts_only() {
868        let dir = tempfile::tempdir().unwrap();
869        std::fs::write(
870            dir.path().join("package.json"),
871            r#"{
872                "packageManager": "pnpm@9.0.0",
873                "scripts": {
874                    "test": "vitest",
875                    "lint": "eslint ."
876                }
877            }"#,
878        )
879        .unwrap();
880
881        let presets = verification_presets_for_workspace(dir.path());
882
883        assert_eq!(presets.len(), 1);
884        assert_eq!(presets[0].project_kind, "node");
885        assert_eq!(presets[0].commands.len(), 2);
886        assert_eq!(presets[0].commands[0].command, "pnpm test");
887        assert_eq!(presets[0].commands[1].command, "pnpm lint");
888    }
889
890    #[test]
891    fn python_workspace_preset_requires_clear_markers() {
892        let dir = tempfile::tempdir().unwrap();
893        std::fs::write(
894            dir.path().join("pyproject.toml"),
895            "[tool.pytest.ini_options]\n[tool.ruff]\n",
896        )
897        .unwrap();
898
899        let presets = verification_presets_for_workspace(dir.path());
900
901        assert_eq!(presets.len(), 1);
902        assert_eq!(presets[0].project_kind, "python");
903        assert_eq!(presets[0].commands[0].command, "python -m pytest");
904        assert_eq!(presets[0].commands[1].command, "python -m ruff check .");
905    }
906
907    #[test]
908    fn summary_skips_empty_reports() {
909        let summary = VerificationSummary::from_reports(&[]);
910
911        assert_eq!(summary.status, VerificationStatus::Skipped);
912        assert_eq!(summary.report_count, 0);
913        assert!(summary.is_complete());
914    }
915
916    #[test]
917    fn summary_tracks_pending_required_checks() {
918        let report = VerificationReport::new(
919            "program:search",
920            vec![VerificationCheck::required(
921                "check:inspect",
922                "inspect_matches",
923                "Inspect matches",
924            )],
925        );
926
927        let summary = VerificationSummary::from_reports(&[report]);
928
929        assert_eq!(summary.status, VerificationStatus::NeedsReview);
930        assert_eq!(summary.report_count, 1);
931        assert_eq!(summary.required_check_count, 1);
932        assert_eq!(summary.pending_required_check_count, 1);
933        assert_eq!(summary.pending_subjects, vec!["program:search"]);
934        assert!(!summary.is_complete());
935    }
936
937    #[test]
938    fn summary_prioritizes_failed_checks() {
939        let failed = VerificationReport::new(
940            "program:test",
941            vec![
942                VerificationCheck::required("check:test", "test", "Run tests")
943                    .with_status(VerificationStatus::Failed),
944            ],
945        );
946        let pending = VerificationReport::new(
947            "program:search",
948            vec![VerificationCheck::required(
949                "check:inspect",
950                "inspect_matches",
951                "Inspect matches",
952            )],
953        );
954
955        let summary = VerificationSummary::from_reports(&[pending, failed]);
956
957        assert_eq!(summary.status, VerificationStatus::Failed);
958        assert_eq!(summary.failed_check_count, 1);
959        assert_eq!(summary.failed_subjects, vec!["program:test"]);
960        assert!(summary.is_complete());
961    }
962
963    #[test]
964    fn summary_passes_when_reports_pass() {
965        let report = VerificationReport::new(
966            "turn",
967            vec![
968                VerificationCheck::required("check:build", "build", "Run build")
969                    .with_status(VerificationStatus::Passed),
970            ],
971        );
972
973        let summary = VerificationSummary::from_reports(&[report]);
974
975        assert_eq!(summary.status, VerificationStatus::Passed);
976        assert_eq!(summary.pending_required_check_count, 0);
977        assert_eq!(summary.failed_check_count, 0);
978    }
979
980    #[test]
981    fn format_summary_includes_actionable_counts_and_subjects() {
982        let failed = VerificationReport::new(
983            "program:test",
984            vec![
985                VerificationCheck::required("check:test", "test", "Run tests")
986                    .with_status(VerificationStatus::Failed),
987            ],
988        );
989        let pending = VerificationReport::new(
990            "program:search",
991            vec![VerificationCheck::required(
992                "check:review",
993                "review",
994                "Review matches",
995            )],
996        );
997
998        let summary = VerificationSummary::from_reports(&[failed, pending]);
999        let text = format_verification_summary(&summary);
1000
1001        assert!(text.contains("Verification failed"));
1002        assert!(text.contains("1 failed check"));
1003        assert!(text.contains("program:test"));
1004        assert!(text.contains("2 reports"));
1005        assert!(text.contains("2 required checks"));
1006    }
1007
1008    #[test]
1009    fn format_summary_skipped_mentions_no_reports() {
1010        let summary = VerificationSummary::from_reports(&[]);
1011
1012        assert_eq!(
1013            format_verification_summary(&summary),
1014            "Verification skipped: no reports."
1015        );
1016    }
1017
1018    #[test]
1019    fn format_summary_needs_review_mentions_pending_subject() {
1020        let report = VerificationReport::new(
1021            "program:search",
1022            vec![VerificationCheck::required(
1023                "check:review",
1024                "review",
1025                "Review matches",
1026            )],
1027        );
1028        let summary = VerificationSummary::from_reports(&[report]);
1029        let text = format_verification_summary(&summary);
1030
1031        assert!(text.contains("Verification needs review"));
1032        assert!(text.contains("1 pending required check"));
1033        assert!(text.contains("program:search"));
1034    }
1035
1036    #[test]
1037    fn format_summary_mentions_residual_risks() {
1038        let report = VerificationReport::new(
1039            "turn",
1040            vec![
1041                VerificationCheck::required("check:build", "build", "Run build")
1042                    .with_status(VerificationStatus::Passed)
1043                    .with_residual_risk("build did not cover integration tests"),
1044            ],
1045        );
1046        let summary = VerificationSummary::from_reports(&[report]);
1047        let text = format_verification_summary(&summary);
1048
1049        assert!(text.contains("Verification needs review"));
1050        assert!(text.contains("Residual risks: 1."));
1051    }
1052}