1use 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}