1use crate::program::ProgramVerificationHint;
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
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 #[serde(default)]
117 pub expect_exit: i32,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct VerificationPreset {
122 pub id: String,
123 pub project_kind: String,
124 pub description: String,
125 pub commands: Vec<VerificationCommand>,
126}
127
128impl VerificationPreset {
129 pub fn new(
130 id: impl Into<String>,
131 project_kind: impl Into<String>,
132 description: impl Into<String>,
133 commands: Vec<VerificationCommand>,
134 ) -> Self {
135 Self {
136 id: id.into(),
137 project_kind: project_kind.into(),
138 description: description.into(),
139 commands,
140 }
141 }
142}
143
144impl VerificationCommand {
145 pub fn required(
146 id: impl Into<String>,
147 kind: impl Into<String>,
148 description: impl Into<String>,
149 command: impl Into<String>,
150 ) -> Self {
151 Self {
152 id: id.into(),
153 kind: kind.into(),
154 description: description.into(),
155 command: command.into(),
156 required: true,
157 timeout_ms: None,
158 expect_exit: 0,
159 }
160 }
161
162 pub fn optional(
163 id: impl Into<String>,
164 kind: impl Into<String>,
165 description: impl Into<String>,
166 command: impl Into<String>,
167 ) -> Self {
168 Self {
169 required: false,
170 ..Self::required(id, kind, description, command)
171 }
172 }
173
174 pub fn with_expect_exit(mut self, expect_exit: i32) -> Self {
175 self.expect_exit = expect_exit;
176 self
177 }
178
179 pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
180 self.timeout_ms = Some(timeout_ms);
181 self
182 }
183
184 pub fn to_check(&self) -> VerificationCheck {
185 let check = if self.required {
186 VerificationCheck::required(
187 self.id.clone(),
188 self.kind.clone(),
189 self.description.clone(),
190 )
191 } else {
192 VerificationCheck::optional(
193 self.id.clone(),
194 self.kind.clone(),
195 self.description.clone(),
196 )
197 };
198
199 check.with_suggested_tools(["bash"])
200 }
201
202 pub fn check_from_execution(
203 &self,
204 exit_code: i32,
205 metadata: Option<&serde_json::Value>,
206 execution_error: Option<&str>,
207 ) -> VerificationCheck {
208 let passed = exit_code == self.expect_exit && execution_error.is_none();
209 let mut check = self.to_check().with_status(if passed {
210 VerificationStatus::Passed
211 } else {
212 VerificationStatus::Failed
213 });
214
215 let evidence_uris = artifact_uris(metadata);
216 if !evidence_uris.is_empty() {
217 check = check.with_evidence_uris(evidence_uris);
218 }
219
220 if let Some(error) = execution_error {
221 return check
222 .with_residual_risk(format!("verification command could not run: {error}"));
223 }
224
225 if exit_code != self.expect_exit {
226 check = check.with_residual_risk(format!(
227 "verification command exited with code {exit_code}, expected {}: {}",
228 self.expect_exit, self.command
229 ));
230 }
231
232 check
233 }
234}
235
236pub fn verification_presets_for_workspace(workspace: impl AsRef<Path>) -> Vec<VerificationPreset> {
237 let workspace = workspace.as_ref();
238 let mut presets = Vec::new();
239
240 if workspace.join("Cargo.toml").is_file() {
241 presets.push(VerificationPreset::new(
242 "rust-default",
243 "rust",
244 "Rust cargo verification",
245 vec![
246 VerificationCommand::required(
247 "rust:fmt",
248 "format",
249 "Check Rust formatting",
250 "cargo fmt -- --check",
251 ),
252 VerificationCommand::required(
253 "rust:check",
254 "type_check",
255 "Run Rust type checking",
256 "cargo check",
257 ),
258 VerificationCommand::required("rust:test", "test", "Run Rust tests", "cargo test"),
259 VerificationCommand::optional(
260 "rust:clippy",
261 "lint",
262 "Run Rust clippy lints",
263 "cargo clippy -- -D warnings",
264 ),
265 ],
266 ));
267 }
268
269 if workspace.join("package.json").is_file() {
270 if let Some(preset) = node_verification_preset(workspace) {
271 presets.push(preset);
272 }
273 }
274
275 if workspace.join("pyproject.toml").is_file() || workspace.join("pytest.ini").is_file() {
276 let mut commands = Vec::new();
277 if workspace.join("tests").is_dir()
278 || file_contains(&workspace.join("pyproject.toml"), "[tool.pytest")
279 || workspace.join("pytest.ini").is_file()
280 {
281 commands.push(VerificationCommand::required(
282 "python:test",
283 "test",
284 "Run Python tests",
285 "python -m pytest",
286 ));
287 }
288 if workspace.join("ruff.toml").is_file()
289 || workspace.join(".ruff.toml").is_file()
290 || file_contains(&workspace.join("pyproject.toml"), "[tool.ruff")
291 {
292 commands.push(VerificationCommand::optional(
293 "python:ruff",
294 "lint",
295 "Run Ruff lint checks",
296 "python -m ruff check .",
297 ));
298 }
299 if workspace.join("mypy.ini").is_file()
300 || workspace.join(".mypy.ini").is_file()
301 || file_contains(&workspace.join("pyproject.toml"), "[tool.mypy")
302 {
303 commands.push(VerificationCommand::optional(
304 "python:mypy",
305 "type_check",
306 "Run mypy type checking",
307 "python -m mypy .",
308 ));
309 }
310 if !commands.is_empty() {
311 presets.push(VerificationPreset::new(
312 "python-default",
313 "python",
314 "Python project verification",
315 commands,
316 ));
317 }
318 }
319
320 if workspace.join("go.mod").is_file() {
321 presets.push(VerificationPreset::new(
322 "go-default",
323 "go",
324 "Go module verification",
325 vec![
326 VerificationCommand::required("go:test", "test", "Run Go tests", "go test ./..."),
327 VerificationCommand::optional("go:vet", "lint", "Run go vet", "go vet ./..."),
328 ],
329 ));
330 }
331
332 presets
333}
334
335fn node_verification_preset(workspace: &Path) -> Option<VerificationPreset> {
336 let package_json = std::fs::read_to_string(workspace.join("package.json")).ok()?;
337 let package: serde_json::Value = serde_json::from_str(&package_json).ok()?;
338 let scripts = package.get("scripts").and_then(|value| value.as_object())?;
339 let package_manager = detect_node_package_manager(workspace, &package);
340 let mut commands = Vec::new();
341
342 for (script, kind, description, required) in [
343 ("test", "test", "Run JavaScript tests", true),
344 (
345 "typecheck",
346 "type_check",
347 "Run JavaScript type checks",
348 false,
349 ),
350 ("lint", "lint", "Run JavaScript lint checks", false),
351 ] {
352 if scripts.contains_key(script) {
353 let command = node_script_command(&package_manager, script);
354 let id = format!("node:{script}");
355 let verification = if required {
356 VerificationCommand::required(id, kind, description, command)
357 } else {
358 VerificationCommand::optional(id, kind, description, command)
359 };
360 commands.push(verification);
361 }
362 }
363
364 if commands.is_empty() {
365 return None;
366 }
367
368 Some(VerificationPreset::new(
369 "node-default",
370 "node",
371 "Node.js package verification",
372 commands,
373 ))
374}
375
376fn detect_node_package_manager(workspace: &Path, package: &serde_json::Value) -> String {
377 if let Some(manager) = package
378 .get("packageManager")
379 .and_then(|value| value.as_str())
380 {
381 if let Some((name, _)) = manager.split_once('@') {
382 return name.to_string();
383 }
384 }
385
386 if workspace.join("pnpm-lock.yaml").is_file() {
387 "pnpm".to_string()
388 } else if workspace.join("yarn.lock").is_file() {
389 "yarn".to_string()
390 } else if workspace.join("bun.lockb").is_file() || workspace.join("bun.lock").is_file() {
391 "bun".to_string()
392 } else {
393 "npm".to_string()
394 }
395}
396
397fn node_script_command(package_manager: &str, script: &str) -> String {
398 match package_manager {
399 "pnpm" | "yarn" => format!("{package_manager} {script}"),
400 "bun" => format!("bun run {script}"),
401 "npm" if script == "test" => "npm test".to_string(),
402 "npm" => format!("npm run {script}"),
403 other => format!("{other} run {script}"),
404 }
405}
406
407fn file_contains(path: &Path, needle: &str) -> bool {
408 std::fs::read_to_string(path)
409 .map(|content| content.contains(needle))
410 .unwrap_or(false)
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct VerificationReport {
415 pub schema: String,
416 pub subject: String,
417 pub status: VerificationStatus,
418 pub checks: Vec<VerificationCheck>,
419 #[serde(default, skip_serializing_if = "Vec::is_empty")]
420 pub residual_risks: Vec<String>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub effect_digest: Option<String>,
424}
425
426impl VerificationReport {
427 pub fn new(subject: impl Into<String>, checks: Vec<VerificationCheck>) -> Self {
428 let mut report = Self {
429 schema: VERIFICATION_REPORT_SCHEMA.to_string(),
430 subject: subject.into(),
431 status: VerificationStatus::Skipped,
432 checks,
433 residual_risks: Vec::new(),
434 effect_digest: None,
435 };
436 report.status = report.derive_status();
437 report
438 }
439
440 pub fn from_program_hints(subject: &str, hints: &[ProgramVerificationHint]) -> Self {
441 let checks = hints
442 .iter()
443 .enumerate()
444 .map(|(index, hint)| VerificationCheck::from_program_hint(subject, index, hint))
445 .collect();
446 Self::new(format!("program:{subject}"), checks)
447 }
448
449 pub fn with_effect_digest(mut self, digest: impl Into<String>) -> Self {
450 self.effect_digest = Some(digest.into());
451 self
452 }
453
454 pub fn with_residual_risk(mut self, risk: impl Into<String>) -> Self {
455 self.residual_risks.push(risk.into());
456 self.status = self.derive_status();
457 self
458 }
459
460 pub fn is_complete(&self) -> bool {
461 !matches!(self.status, VerificationStatus::NeedsReview)
462 }
463
464 pub fn to_value(&self) -> serde_json::Value {
465 serde_json::to_value(self).unwrap_or_else(|_| {
466 serde_json::json!({
467 "schema": VERIFICATION_REPORT_SCHEMA,
468 "subject": self.subject,
469 "status": "failed",
470 "checks": [],
471 "residual_risks": ["failed to serialize verification report"],
472 })
473 })
474 }
475
476 fn derive_status(&self) -> VerificationStatus {
477 if self
478 .checks
479 .iter()
480 .any(|check| check.status == VerificationStatus::Failed)
481 {
482 return VerificationStatus::Failed;
483 }
484
485 if self.checks.iter().any(|check| {
486 check.required
487 && matches!(
488 check.status,
489 VerificationStatus::NeedsReview | VerificationStatus::Skipped
490 )
491 }) {
492 return VerificationStatus::NeedsReview;
493 }
494
495 if !self.residual_risks.is_empty() {
496 return VerificationStatus::NeedsReview;
497 }
498
499 if self.checks.is_empty() {
500 VerificationStatus::Skipped
501 } else {
502 VerificationStatus::Passed
503 }
504 }
505}
506
507fn artifact_uris(metadata: Option<&serde_json::Value>) -> Vec<String> {
508 let mut uris = Vec::new();
509 if let Some(metadata) = metadata {
510 collect_artifact_uris(metadata, &mut uris);
511 }
512 uris.sort();
513 uris.dedup();
514 uris
515}
516
517fn collect_artifact_uris(value: &serde_json::Value, uris: &mut Vec<String>) {
518 match value {
519 serde_json::Value::Object(object) => {
520 if let Some(uri) = object.get("artifact_uri").and_then(|value| value.as_str()) {
521 uris.push(uri.to_string());
522 }
523 for value in object.values() {
524 collect_artifact_uris(value, uris);
525 }
526 }
527 serde_json::Value::Array(items) => {
528 for value in items {
529 collect_artifact_uris(value, uris);
530 }
531 }
532 _ => {}
533 }
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537pub struct VerificationSummary {
538 pub status: VerificationStatus,
539 pub report_count: usize,
540 pub required_check_count: usize,
541 pub pending_required_check_count: usize,
542 pub failed_check_count: usize,
543 pub residual_risk_count: usize,
544 #[serde(default, skip_serializing_if = "Vec::is_empty")]
545 pub pending_subjects: Vec<String>,
546 #[serde(default, skip_serializing_if = "Vec::is_empty")]
547 pub failed_subjects: Vec<String>,
548}
549
550impl VerificationSummary {
551 pub fn from_reports(reports: &[VerificationReport]) -> Self {
552 let mut required_check_count = 0;
553 let mut pending_required_check_count = 0;
554 let mut failed_check_count = 0;
555 let mut residual_risk_count = 0;
556 let mut pending_subjects = Vec::new();
557 let mut failed_subjects = Vec::new();
558
559 for report in reports {
560 if matches!(report.status, VerificationStatus::NeedsReview) {
561 pending_subjects.push(report.subject.clone());
562 }
563
564 if matches!(report.status, VerificationStatus::Failed) {
565 failed_subjects.push(report.subject.clone());
566 }
567
568 residual_risk_count += report.residual_risks.len();
569
570 for check in &report.checks {
571 if check.required {
572 required_check_count += 1;
573 if matches!(
574 check.status,
575 VerificationStatus::NeedsReview | VerificationStatus::Skipped
576 ) {
577 pending_required_check_count += 1;
578 pending_subjects.push(report.subject.clone());
579 }
580 }
581
582 if check.status == VerificationStatus::Failed {
583 failed_check_count += 1;
584 failed_subjects.push(report.subject.clone());
585 }
586
587 if check.residual_risk.is_some() {
588 residual_risk_count += 1;
589 pending_subjects.push(report.subject.clone());
590 }
591 }
592 }
593
594 pending_subjects.sort();
595 pending_subjects.dedup();
596 failed_subjects.sort();
597 failed_subjects.dedup();
598
599 let status = if failed_check_count > 0
600 || reports
601 .iter()
602 .any(|report| report.status == VerificationStatus::Failed)
603 {
604 VerificationStatus::Failed
605 } else if pending_required_check_count > 0
606 || residual_risk_count > 0
607 || reports
608 .iter()
609 .any(|report| report.status == VerificationStatus::NeedsReview)
610 {
611 VerificationStatus::NeedsReview
612 } else if reports.is_empty() {
613 VerificationStatus::Skipped
614 } else {
615 VerificationStatus::Passed
616 };
617
618 Self {
619 status,
620 report_count: reports.len(),
621 required_check_count,
622 pending_required_check_count,
623 failed_check_count,
624 residual_risk_count,
625 pending_subjects,
626 failed_subjects,
627 }
628 }
629
630 pub fn is_complete(&self) -> bool {
631 !matches!(self.status, VerificationStatus::NeedsReview)
632 }
633
634 pub fn supports_goal_achievement(&self) -> bool {
640 matches!(self.status, VerificationStatus::Passed)
641 && self.report_count > 0
642 && self.required_check_count > 0
643 && self.pending_required_check_count == 0
644 && self.failed_check_count == 0
645 && self.residual_risk_count == 0
646 }
647
648 pub fn to_value(&self) -> serde_json::Value {
649 serde_json::to_value(self).unwrap_or_else(|_| {
650 serde_json::json!({
651 "status": "failed",
652 "report_count": self.report_count,
653 "required_check_count": self.required_check_count,
654 "pending_required_check_count": self.pending_required_check_count,
655 "failed_check_count": self.failed_check_count,
656 "residual_risk_count": self.residual_risk_count,
657 "failed_subjects": ["failed to serialize verification summary"],
658 })
659 })
660 }
661}
662
663pub fn normalize_shell_command(command: &str) -> String {
665 command.split_whitespace().collect::<Vec<_>>().join(" ")
666}
667
668pub fn shell_command_covers_preset(command: &str, preset: &str) -> bool {
670 let command = normalize_shell_command(command);
671 let preset = normalize_shell_command(preset);
672 if command.is_empty() || preset.is_empty() {
673 return false;
674 }
675 if command == preset || command.starts_with(&format!("{preset} ")) {
676 return true;
677 }
678 let and_prefix = format!("&& {preset}");
679 let semi_prefix = format!("; {preset}");
680 command.contains(&format!("{and_prefix} "))
681 || command.ends_with(&and_prefix)
682 || command.contains(&format!("{semi_prefix} "))
683 || command.ends_with(&semi_prefix)
684}
685
686pub fn shell_verification_report_for_command(
690 workspace: &Path,
691 command: &str,
692 exit_code: i32,
693 metadata: Option<&serde_json::Value>,
694 execution_error: Option<&str>,
695) -> Option<VerificationReport> {
696 let matched = verification_presets_for_workspace(workspace)
697 .into_iter()
698 .flat_map(|preset| preset.commands)
699 .chain(acceptance_shell_commands_for_workspace(workspace))
700 .find(|preset_command| shell_command_covers_preset(command, &preset_command.command))?;
701 let check = matched.check_from_execution(exit_code, metadata, execution_error);
702 Some(VerificationReport::new(
703 format!("shell:{}", matched.id),
704 vec![check],
705 ))
706}
707
708const ACTIVE_GOAL_LOOP_STATUSES: &[&str] = &["running", "retrying", "paused"];
713
714fn loop_owns_active_acceptance_contract(loop_dir: &Path) -> bool {
716 let Ok(state) = std::fs::read_to_string(loop_dir.join("STATE.md")) else {
717 return false;
718 };
719 for line in state.lines() {
720 let trimmed = line.trim();
721 let Some(status) = trimmed.strip_prefix("Status:") else {
722 continue;
723 };
724 let status = status.trim();
725 return ACTIVE_GOAL_LOOP_STATUSES
726 .iter()
727 .any(|allowed| status.eq_ignore_ascii_case(allowed));
728 }
729 false
730}
731
732pub fn acceptance_shell_commands_for_workspace(
742 workspace: impl AsRef<Path>,
743) -> Vec<VerificationCommand> {
744 let loops = workspace.as_ref().join(".a3s").join("loops");
745 let Ok(entries) = std::fs::read_dir(&loops) else {
746 return Vec::new();
747 };
748 let mut commands = Vec::new();
749 for entry in entries.flatten() {
750 let path = entry.path();
751 if !path.is_dir() {
752 continue;
753 }
754 if !loop_owns_active_acceptance_contract(&path) {
755 continue;
756 }
757 let acceptance = path.join("ACCEPTANCE.md");
758 let Ok(body) = std::fs::read_to_string(&acceptance) else {
759 continue;
760 };
761 let loop_id = path
762 .file_name()
763 .and_then(|name| name.to_str())
764 .unwrap_or("goal");
765 commands.extend(parse_acceptance_shell_commands(
766 &body,
767 loop_id,
768 workspace.as_ref(),
769 ));
770 }
771 commands
772}
773
774fn parse_acceptance_shell_commands(
775 body: &str,
776 loop_id: &str,
777 workspace: &Path,
778) -> Vec<VerificationCommand> {
779 let mut commands = Vec::new();
780 for (index, line) in body.lines().enumerate() {
781 let trimmed = line.trim_start();
782 let rest = if let Some(rest) = trimmed.strip_prefix("- [") {
783 rest
784 } else if let Some(rest) = trimmed.strip_prefix("* [") {
785 rest
786 } else {
787 continue;
788 };
789 let Some((_mark, body)) = rest.split_once(']') else {
790 continue;
791 };
792 let body = body.trim().trim_start_matches(':').trim();
793 let lower = body.to_ascii_lowercase();
794 let line_id = format!("acceptance:{loop_id}:{}", index + 1);
795 if lower.starts_with("kind:command") {
796 let Some(command) = extract_acceptance_assert_command(body) else {
797 continue;
798 };
799 let expect_exit = extract_acceptance_expect_exit(body).unwrap_or(0);
800 commands.push(
801 VerificationCommand::required(
802 line_id,
803 "acceptance_command",
804 format!("ACCEPTANCE kind:command ({loop_id})"),
805 command,
806 )
807 .with_expect_exit(expect_exit),
808 );
809 continue;
810 }
811 if lower.starts_with("kind:file_exists") || lower.starts_with("kind:file-exists") {
812 let Some(path) = extract_acceptance_assert_command(body) else {
813 continue;
814 };
815 if !acceptance_file_path_allowed_in_workspace(workspace, &path) {
818 continue;
819 }
820 let command = format!("test -f {}", shell_quote_acceptance_path(&path));
821 commands.push(VerificationCommand::required(
822 line_id,
823 "acceptance_file_exists",
824 format!("ACCEPTANCE kind:file_exists ({loop_id})"),
825 command,
826 ));
827 }
828 }
829 commands
830}
831
832fn acceptance_file_path_allowed_in_workspace(workspace: &Path, path: &str) -> bool {
838 if path.is_empty() {
839 return false;
840 }
841 let p = Path::new(path);
842 if !p.is_absolute() {
843 let mut depth = 0i32;
844 for component in p.components() {
845 match component {
846 std::path::Component::ParentDir => {
847 depth -= 1;
848 if depth < 0 {
849 return false;
850 }
851 }
852 std::path::Component::Normal(_) => depth += 1,
853 std::path::Component::RootDir | std::path::Component::Prefix(_) => return false,
854 std::path::Component::CurDir => {}
855 }
856 }
857 return true;
858 }
859 let Ok(workspace_canon) = workspace.canonicalize() else {
860 return false;
861 };
862 let candidate = PathBuf::from(path);
863 if !candidate.is_file() {
864 return false;
865 }
866 let Ok(file_canon) = candidate.canonicalize() else {
867 return false;
868 };
869 file_canon.starts_with(&workspace_canon)
870}
871
872fn shell_quote_acceptance_path(path: &str) -> String {
874 if path.is_empty() {
875 return "''".to_string();
876 }
877 if path
878 .chars()
879 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
880 {
881 return path.to_string();
882 }
883 format!("'{}'", path.replace('\'', "'\\''"))
884}
885
886fn extract_acceptance_assert_command(body: &str) -> Option<String> {
887 let idx = body.to_ascii_lowercase().find("assert:")?;
888 let after = body[idx + "assert:".len()..].trim_start();
889 if let Some(rest) = after.strip_prefix('`') {
890 let end = rest.find('`')?;
891 let command = rest[..end].trim();
892 if command.is_empty() {
893 return None;
894 }
895 return Some(command.to_string());
896 }
897 let command = after
898 .split_whitespace()
899 .next()
900 .filter(|token| !token.to_ascii_lowercase().starts_with("expect:"))?;
901 Some(command.to_string())
902}
903
904fn extract_acceptance_expect_exit(body: &str) -> Option<i32> {
905 let lower = body.to_ascii_lowercase();
906 let idx = lower.find("expect:exit=")?;
907 let after = &body[idx + "expect:exit=".len()..];
908 let digits: String = after
909 .chars()
910 .take_while(|c| c.is_ascii_digit() || *c == '-')
911 .collect();
912 digits.parse().ok()
913}
914
915pub fn merge_shell_verification_metadata(
917 metadata: Option<serde_json::Value>,
918 workspace: Option<&Path>,
919 command: &str,
920 exit_code: i32,
921 execution_error: Option<&str>,
922) -> Option<serde_json::Value> {
923 let mut metadata = metadata.unwrap_or_else(|| serde_json::json!({}));
924 if let Some(object) = metadata.as_object_mut() {
925 object.insert(
926 "verification_shell_command".to_string(),
927 serde_json::Value::String(command.to_string()),
928 );
929 }
930 let Some(workspace) = workspace else {
931 return Some(metadata);
932 };
933 if metadata.get("verification_report").is_some() {
934 return Some(metadata);
935 }
936 let Some(report) = shell_verification_report_for_command(
937 workspace,
938 command,
939 exit_code,
940 Some(&metadata),
941 execution_error,
942 ) else {
943 return Some(metadata);
944 };
945 if let Some(object) = metadata.as_object_mut() {
946 object.insert("verification_report".to_string(), report.to_value());
947 }
948 Some(metadata)
949}
950
951#[derive(Debug, Clone, PartialEq, Eq)]
953pub(crate) struct ExistenceCheck {
954 pub segment: String,
955 pub path: String,
956}
957
958pub(crate) fn existence_checks(command: &str) -> Vec<ExistenceCheck> {
965 shell_segments(command)
966 .into_iter()
967 .filter_map(|segment| {
968 let path = path_from_single_existence_check(segment)?;
969 Some(ExistenceCheck {
970 segment: segment.to_string(),
971 path,
972 })
973 })
974 .collect()
975}
976
977pub fn path_from_existence_check_command(command: &str) -> Option<String> {
982 existence_checks(command)
983 .into_iter()
984 .next()
985 .map(|check| check.path)
986}
987
988fn path_from_single_existence_check(command: &str) -> Option<String> {
989 let trimmed = command.trim();
990 let trimmed = trimmed
991 .strip_prefix("command ")
992 .map(str::trim_start)
993 .unwrap_or(trimmed);
994 if let Some(rest) = trimmed.strip_prefix("[[") {
995 let rest = rest.trim_start();
996 let rest = rest
997 .strip_prefix("-f")
998 .or_else(|| rest.strip_prefix("-e"))?
999 .trim_start();
1000 let rest = rest.trim_end().strip_suffix("]]")?.trim();
1001 return unquote_shell_token(rest);
1002 }
1003 let rest = if let Some(rest) = trimmed.strip_prefix("test") {
1004 let rest = rest.trim_start();
1005 let rest = rest
1006 .strip_prefix("-f")
1007 .or_else(|| rest.strip_prefix("-e"))?
1008 .trim_start();
1009 rest
1010 } else {
1011 let rest = trimmed.strip_prefix('[')?;
1012 let rest = rest.trim_start();
1013 let rest = rest
1014 .strip_prefix("-f")
1015 .or_else(|| rest.strip_prefix("-e"))?
1016 .trim_start();
1017 rest.trim_end()
1018 .trim_end_matches(']')
1019 .trim_end()
1020 .trim_start()
1021 };
1022 unquote_shell_token(rest)
1023}
1024
1025fn shell_segments(command: &str) -> Vec<&str> {
1028 let mut segments = Vec::new();
1029 let mut start = 0;
1030 let mut quote: Option<char> = None;
1031 let chars: Vec<(usize, char)> = command.char_indices().collect();
1032 let mut index = 0;
1033 while index < chars.len() {
1034 let (byte, ch) = chars[index];
1035 if let Some(open) = quote {
1036 if ch == open {
1037 quote = None;
1038 }
1039 index += 1;
1040 continue;
1041 }
1042 if ch == '\'' || ch == '"' {
1043 quote = Some(ch);
1044 index += 1;
1045 continue;
1046 }
1047 let doubled = index + 1 < chars.len()
1048 && ((ch == '&' && chars[index + 1].1 == '&')
1049 || (ch == '|' && chars[index + 1].1 == '|'));
1050 if doubled || ch == ';' || ch == '\n' {
1051 let segment = command[start..byte].trim();
1052 if !segment.is_empty() {
1053 segments.push(segment);
1054 }
1055 index += if doubled { 2 } else { 1 };
1056 start = chars
1057 .get(index)
1058 .map(|(next, _)| *next)
1059 .unwrap_or(command.len());
1060 continue;
1061 }
1062 index += 1;
1063 }
1064 let tail = command[start..].trim();
1065 if !tail.is_empty() {
1066 segments.push(tail);
1067 }
1068 segments
1069}
1070
1071fn unquote_shell_token(token: &str) -> Option<String> {
1072 let token = token.trim();
1073 if token.is_empty() {
1074 return None;
1075 }
1076 if let Some(inner) = token.strip_prefix('\'') {
1077 let end = inner.find('\'')?;
1078 return Some(inner[..end].replace("'\\''", "'"));
1079 }
1080 if let Some(inner) = token.strip_prefix('"') {
1081 let end = inner.find('"')?;
1082 return Some(inner[..end].to_string());
1083 }
1084 Some(token.split_whitespace().next()?.to_string())
1085}
1086
1087pub(crate) fn mutation_path_matches(mutated: &str, verified: &str) -> bool {
1089 let mutated = mutated.trim_start_matches("./");
1090 let verified = verified.trim_start_matches("./");
1091 if mutated.is_empty() || verified.is_empty() {
1092 return false;
1093 }
1094 if mutated == verified {
1095 return true;
1096 }
1097 mutated.ends_with(&format!("/{verified}")) || verified.ends_with(&format!("/{mutated}"))
1100}
1101
1102fn report_is_required_pass(report: &VerificationReport) -> bool {
1103 matches!(report.status, VerificationStatus::Passed)
1104 && report.checks.iter().any(|check| check.required)
1105 && report
1106 .checks
1107 .iter()
1108 .filter(|check| check.required)
1109 .all(|check| matches!(check.status, VerificationStatus::Passed))
1110}
1111
1112pub fn bind_host_shell_reports_to_mutations(
1120 reports: &mut [VerificationReport],
1121 shell_command: Option<&str>,
1122 mutation_paths: &[String],
1123 effect_digest: &str,
1124) {
1125 bind_host_shell_reports_to_mutations_with_content(
1126 reports,
1127 shell_command,
1128 mutation_paths,
1129 effect_digest,
1130 None,
1131 );
1132}
1133
1134pub fn bind_host_shell_reports_to_mutations_with_content(
1137 reports: &mut [VerificationReport],
1138 shell_command: Option<&str>,
1139 mutation_paths: &[String],
1140 effect_digest: &str,
1141 content_match: Option<(&str, &str)>,
1142) {
1143 if mutation_paths.is_empty() || effect_digest.trim().is_empty() {
1144 return;
1145 }
1146 let Some(path) = path_from_existence_check_command(shell_command.unwrap_or("")) else {
1147 return;
1148 };
1149 if !mutation_paths
1150 .iter()
1151 .any(|mutated| mutation_path_matches(mutated, &path))
1152 {
1153 return;
1154 }
1155 if let Some((expected, on_disk)) = content_match {
1156 if expected.trim().is_empty() || expected != on_disk {
1157 return;
1158 }
1159 }
1160 for report in reports.iter_mut() {
1161 if report.effect_digest.is_some() {
1162 continue;
1163 }
1164 if !report_is_required_pass(report) {
1165 continue;
1166 }
1167 report.effect_digest = Some(effect_digest.to_string());
1168 }
1169}
1170
1171pub fn host_report_for_verified_mutation_path(
1177 command: &str,
1178 exit_code: i32,
1179 mutation_paths: &[String],
1180 effect_digest: &str,
1181) -> Option<VerificationReport> {
1182 host_report_for_verified_mutation_path_with_content(
1183 command,
1184 exit_code,
1185 mutation_paths,
1186 effect_digest,
1187 None,
1188 )
1189}
1190
1191pub fn host_report_for_verified_mutation_path_with_content(
1193 command: &str,
1194 exit_code: i32,
1195 mutation_paths: &[String],
1196 effect_digest: &str,
1197 content_match: Option<(&str, &str)>,
1198) -> Option<VerificationReport> {
1199 if exit_code != 0 || effect_digest.trim().is_empty() {
1200 return None;
1201 }
1202 let path = path_from_existence_check_command(command)?;
1203 if !mutation_paths
1204 .iter()
1205 .any(|mutated| mutation_path_matches(mutated, &path))
1206 {
1207 return None;
1208 }
1209 if let Some((expected, on_disk)) = content_match {
1210 if expected.trim().is_empty() || expected != on_disk {
1211 return None;
1212 }
1213 }
1214 let description = if content_match.is_some() {
1215 format!("Host verified mutated path exists with matching content digest: {path}")
1216 } else {
1217 format!("Host verified mutated path still exists: {path}")
1218 };
1219 Some(
1220 VerificationReport::new(
1221 format!("shell:mutation_path_verify:{path}"),
1222 vec![VerificationCheck::required(
1223 format!("mutation_path_verify:{path}"),
1224 "mutation_path_verify",
1225 description,
1226 )
1227 .with_status(VerificationStatus::Passed)],
1228 )
1229 .with_effect_digest(effect_digest),
1230 )
1231}
1232
1233pub fn goal_achieved_after_evidence_gate(
1235 llm_achieved: bool,
1236 reports: &[VerificationReport],
1237) -> bool {
1238 llm_achieved && VerificationSummary::from_reports(reports).supports_goal_achievement()
1239}
1240
1241pub fn is_acceptance_verification_subject(subject: &str) -> bool {
1243 subject.starts_with("shell:acceptance:")
1244}
1245
1246pub fn reports_include_passing_acceptance(reports: &[VerificationReport]) -> bool {
1248 reports.iter().any(|report| {
1249 is_acceptance_verification_subject(&report.subject)
1250 && matches!(report.status, VerificationStatus::Passed)
1251 && report
1252 .checks
1253 .iter()
1254 .any(|check| check.required && matches!(check.status, VerificationStatus::Passed))
1255 })
1256}
1257
1258fn reports_include_passing_active_acceptance(
1259 reports: &[VerificationReport],
1260 acceptance_commands: &[VerificationCommand],
1261) -> bool {
1262 let mut subjects_by_loop: std::collections::HashMap<String, std::collections::HashSet<String>> =
1266 std::collections::HashMap::new();
1267 for command in acceptance_commands {
1268 let Some(loop_id) = acceptance_loop_id_from_command_id(&command.id) else {
1269 continue;
1270 };
1271 subjects_by_loop
1272 .entry(loop_id.to_string())
1273 .or_default()
1274 .insert(format!("shell:{}", command.id));
1275 }
1276 if subjects_by_loop.is_empty() {
1277 return false;
1278 }
1279 subjects_by_loop.values().all(|subjects| {
1280 reports.iter().any(|report| {
1281 subjects.contains(&report.subject)
1282 && matches!(report.status, VerificationStatus::Passed)
1283 && report.checks.iter().any(|check| {
1284 check.required && matches!(check.status, VerificationStatus::Passed)
1285 })
1286 })
1287 })
1288}
1289
1290fn acceptance_loop_id_from_command_id(command_id: &str) -> Option<&str> {
1292 let rest = command_id.strip_prefix("acceptance:")?;
1293 let (loop_id, _line) = rest.rsplit_once(':')?;
1294 if loop_id.is_empty() {
1295 return None;
1296 }
1297 Some(loop_id)
1298}
1299
1300pub fn should_emit_goal_achieved_for_workspace(
1308 llm_achieved: bool,
1309 reports: &[VerificationReport],
1310 workspace: Option<&Path>,
1311) -> bool {
1312 if !goal_achieved_after_evidence_gate(llm_achieved, reports) {
1313 return false;
1314 }
1315 let Some(workspace) = workspace else {
1316 return true;
1317 };
1318 let acceptance = acceptance_shell_commands_for_workspace(workspace);
1319 if acceptance.is_empty() {
1320 return true;
1321 }
1322 reports_include_passing_active_acceptance(reports, &acceptance)
1323}
1324
1325pub fn should_emit_goal_achieved(llm_achieved: bool, reports: &[VerificationReport]) -> bool {
1330 should_emit_goal_achieved_for_workspace(llm_achieved, reports, None)
1331}
1332
1333pub fn format_verification_summary(summary: &VerificationSummary) -> String {
1334 let reports = plural(summary.report_count, "report", "reports");
1335 let required_checks = plural(
1336 summary.required_check_count,
1337 "required check",
1338 "required checks",
1339 );
1340
1341 let mut text = match summary.status {
1342 VerificationStatus::Skipped if summary.report_count == 0 => {
1343 "Verification skipped: no reports.".to_string()
1344 }
1345 VerificationStatus::Skipped => format!("Verification skipped: {reports}."),
1346 VerificationStatus::Passed => {
1347 format!("Verification passed: {reports}, {required_checks}.")
1348 }
1349 VerificationStatus::Failed => {
1350 let failed = if summary.failed_check_count > 0 {
1351 plural(summary.failed_check_count, "failed check", "failed checks")
1352 } else {
1353 "failed report".to_string()
1354 };
1355 let subjects = subject_list(&summary.failed_subjects);
1356 if subjects.is_empty() {
1357 format!("Verification failed: {failed}. {reports}, {required_checks}.")
1358 } else {
1359 format!(
1360 "Verification failed: {failed} across subjects: {subjects}. {reports}, {required_checks}."
1361 )
1362 }
1363 }
1364 VerificationStatus::NeedsReview => {
1365 let pending = if summary.pending_required_check_count > 0 {
1366 plural(
1367 summary.pending_required_check_count,
1368 "pending required check",
1369 "pending required checks",
1370 )
1371 } else {
1372 "review required".to_string()
1373 };
1374 let subjects = subject_list(&summary.pending_subjects);
1375 if subjects.is_empty() {
1376 format!("Verification needs review: {pending}. {reports}, {required_checks}.")
1377 } else {
1378 format!(
1379 "Verification needs review: {pending} across subjects: {subjects}. {reports}, {required_checks}."
1380 )
1381 }
1382 }
1383 };
1384
1385 if summary.residual_risk_count > 0 {
1386 text.push(' ');
1387 text.push_str(&format!("Residual risks: {}.", summary.residual_risk_count));
1388 }
1389
1390 text
1391}
1392
1393pub fn verification_status_label(status: VerificationStatus) -> &'static str {
1394 match status {
1395 VerificationStatus::Passed => "passed",
1396 VerificationStatus::Failed => "failed",
1397 VerificationStatus::NeedsReview => "needs_review",
1398 VerificationStatus::Skipped => "skipped",
1399 }
1400}
1401
1402fn plural(count: usize, singular: &str, plural: &str) -> String {
1403 if count == 1 {
1404 format!("1 {singular}")
1405 } else {
1406 format!("{count} {plural}")
1407 }
1408}
1409
1410fn subject_list(subjects: &[String]) -> String {
1411 const MAX_SUBJECTS: usize = 5;
1412 let mut visible: Vec<&str> = subjects
1413 .iter()
1414 .take(MAX_SUBJECTS)
1415 .map(String::as_str)
1416 .collect();
1417 if subjects.len() > MAX_SUBJECTS {
1418 visible.push("...");
1419 }
1420 visible.join(", ")
1421}
1422
1423pub trait Verifier: Send + Sync {
1424 fn verify(&self, checks: Vec<VerificationCheck>) -> Result<VerificationReport>;
1425}
1426
1427#[derive(Debug, Clone)]
1428pub struct StaticVerifier {
1429 subject: String,
1430}
1431
1432impl StaticVerifier {
1433 pub fn new(subject: impl Into<String>) -> Self {
1434 Self {
1435 subject: subject.into(),
1436 }
1437 }
1438}
1439
1440impl Verifier for StaticVerifier {
1441 fn verify(&self, checks: Vec<VerificationCheck>) -> Result<VerificationReport> {
1442 Ok(VerificationReport::new(self.subject.clone(), checks))
1443 }
1444}
1445
1446#[cfg(test)]
1447#[path = "verification/tests.rs"]
1448mod tests;