返回 CodeWhale
verifier.rs
根目录 / crates / tui / src / tools / verifier.rs
1 //! Parallel verifier ensemble tool: `run_verifiers`.
2 //!
3 //! This is the agent-facing path for "parallelize the verifier, not the
4 //! generator": one tool call fans out to independent project checks across
5 //! common ecosystems and returns a single structured verdict.
6
7 use std::collections::{BTreeSet, HashMap};
8 use std::fs;
9 use std::path::{Path, PathBuf};
10 use std::process::{Command, Stdio};
11 use std::time::Instant;
12
13 use async_trait::async_trait;
14 use serde::{Deserialize, Serialize};
15 use serde_json::{Value, json};
16 use shlex::try_join;
17
18 use crate::dependencies::ExternalTool;
19
20 use super::spec::{
21 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
22 };
23
24 const MAX_GATE_OUTPUT_CHARS: usize = 16_000;
25 const DEFAULT_MAX_PYTHON_FILES: usize = 200;
26 const MAX_CUSTOM_GATES: usize = 12;
27 const BACKGROUND_GATE_TIMEOUT_MS: u64 = 600_000;
28
29 /// Tool for running independent verifier gates concurrently.
30 pub struct RunVerifiersTool;
31
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33 #[serde(rename_all = "snake_case")]
34 enum VerifierProfile {
35 Auto,
36 Rust,
37 Node,
38 Python,
39 Go,
40 }
41
42 impl VerifierProfile {
43 fn parse(raw: &str) -> Result<Self, ToolError> {
44 match raw {
45 "auto" => Ok(Self::Auto),
46 "rust" => Ok(Self::Rust),
47 "node" => Ok(Self::Node),
48 "python" => Ok(Self::Python),
49 "go" => Ok(Self::Go),
50 other => Err(ToolError::invalid_input(format!(
51 "Unsupported profile '{other}'. Expected one of: auto, rust, node, python, go"
52 ))),
53 }
54 }
55
56 fn as_str(self) -> &'static str {
57 match self {
58 Self::Auto => "auto",
59 Self::Rust => "rust",
60 Self::Node => "node",
61 Self::Python => "python",
62 Self::Go => "go",
63 }
64 }
65 }
66
67 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
68 #[serde(rename_all = "snake_case")]
69 enum VerifierLevel {
70 Quick,
71 Full,
72 }
73
74 impl VerifierLevel {
75 fn parse(raw: &str) -> Result<Self, ToolError> {
76 match raw {
77 "quick" => Ok(Self::Quick),
78 "full" => Ok(Self::Full),
79 other => Err(ToolError::invalid_input(format!(
80 "Unsupported level '{other}'. Expected one of: quick, full"
81 ))),
82 }
83 }
84
85 fn as_str(self) -> &'static str {
86 match self {
87 Self::Quick => "quick",
88 Self::Full => "full",
89 }
90 }
91 }
92
93 #[derive(Debug, Clone, Deserialize)]
94 #[serde(default, deny_unknown_fields)]
95 struct RunVerifiersInput {
96 profile: String,
97 level: String,
98 max_python_files: usize,
99 commands: Vec<CustomVerifierInput>,
100 background: bool,
101 cwd: Option<String>,
102 }
103
104 impl Default for RunVerifiersInput {
105 fn default() -> Self {
106 Self {
107 profile: "auto".to_string(),
108 level: "quick".to_string(),
109 max_python_files: DEFAULT_MAX_PYTHON_FILES,
110 commands: Vec::new(),
111 background: false,
112 cwd: None,
113 }
114 }
115 }
116
117 #[derive(Debug, Clone, Default, Deserialize)]
118 #[serde(default, deny_unknown_fields)]
119 struct CustomVerifierInput {
120 name: String,
121 program: String,
122 args: Vec<String>,
123 cwd: Option<String>,
124 }
125
126 #[derive(Debug, Clone)]
127 struct VerifierGate {
128 name: String,
129 ecosystem: String,
130 cwd: PathBuf,
131 program: Option<String>,
132 args: Vec<String>,
133 env: Vec<(String, String)>,
134 skipped_reason: Option<String>,
135 }
136
137 #[derive(Debug, Clone, Serialize, Deserialize)]
138 struct GateResult {
139 name: String,
140 ecosystem: String,
141 status: GateStatus,
142 command: String,
143 cwd: String,
144 exit_code: Option<i32>,
145 duration_ms: u64,
146 stdout: String,
147 stderr: String,
148 stdout_truncated: bool,
149 stderr_truncated: bool,
150 skipped_reason: Option<String>,
151 }
152
153 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154 #[serde(rename_all = "snake_case")]
155 enum GateStatus {
156 Passed,
157 Failed,
158 Skipped,
159 }
160
161 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162 #[serde(rename_all = "snake_case")]
163 enum VerifierVerdict {
164 Pass,
165 Partial,
166 Fail,
167 }
168
169 impl VerifierVerdict {
170 fn from_counts(gate_count: usize, failed: usize, skipped: usize) -> Self {
171 if failed > 0 {
172 Self::Fail
173 } else if skipped > 0 || gate_count == 0 {
174 Self::Partial
175 } else {
176 Self::Pass
177 }
178 }
179 }
180
181 #[derive(Debug, Clone, Serialize, Deserialize)]
182 struct RunVerifiersOutput {
183 success: bool,
184 profile: String,
185 level: String,
186 workspace: String,
187 gate_count: usize,
188 passed: usize,
189 failed: usize,
190 skipped: usize,
191 verifier_verdict: VerifierVerdict,
192 summary: String,
193 gates: Vec<GateResult>,
194 }
195
196 #[derive(Debug, Clone, Serialize, Deserialize)]
197 struct BackgroundGateJob {
198 name: String,
199 ecosystem: String,
200 status: String,
201 command: String,
202 cwd: String,
203 task_id: Option<String>,
204 skipped_reason: Option<String>,
205 error: Option<String>,
206 }
207
208 #[derive(Debug, Clone, Serialize, Deserialize)]
209 struct RunVerifiersBackgroundOutput {
210 success: bool,
211 profile: String,
212 level: String,
213 workspace: String,
214 background: bool,
215 gate_count: usize,
216 started: usize,
217 skipped: usize,
218 failed_to_start: usize,
219 summary: String,
220 jobs: Vec<BackgroundGateJob>,
221 }
222
223 #[async_trait]
224 impl ToolSpec for RunVerifiersTool {
225 fn name(&self) -> &'static str {
226 "run_verifiers"
227 }
228
229 fn model_visible(&self) -> bool {
230 false
231 }
232
233 fn description(&self) -> &'static str {
234 "Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects. Supports explicit custom verifier commands as program+args without requiring Bash."
235 }
236
237 fn input_schema(&self) -> Value {
238 json!({
239 "type": "object",
240 "properties": {
241 "profile": {
242 "type": "string",
243 "enum": ["auto", "rust", "node", "python", "go"],
244 "default": "auto",
245 "description": "Which ecosystem verifier set to run. 'auto' detects all supported project types in the workspace."
246 },
247 "level": {
248 "type": "string",
249 "enum": ["quick", "full"],
250 "default": "quick",
251 "description": "Quick runs fast syntax/drift/build checks. Full adds heavier test/lint gates where available."
252 },
253 "max_python_files": {
254 "type": "integer",
255 "minimum": 1,
256 "maximum": 1000,
257 "default": DEFAULT_MAX_PYTHON_FILES,
258 "description": "Maximum Python files to syntax-parse in the built-in python-syntax gate."
259 },
260 "commands": {
261 "type": "array",
262 "description": "Optional explicit verifier gates. Commands run directly as program+args, not through a shell. Use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.",
263 "items": {
264 "type": "object",
265 "properties": {
266 "name": {
267 "type": "string",
268 "description": "Short unique gate name."
269 },
270 "program": {
271 "type": "string",
272 "description": "Executable to spawn, for example 'uv', 'pytest', 'npm', 'make', 'cmd', 'powershell', or 'bash'."
273 },
274 "args": {
275 "type": "array",
276 "items": { "type": "string" },
277 "description": "Arguments passed directly to the executable."
278 },
279 "cwd": {
280 "type": "string",
281 "description": "Optional working directory relative to the workspace."
282 }
283 },
284 "required": ["name", "program"],
285 "additionalProperties": false
286 },
287 },
288 "background": {
289 "type": "boolean",
290 "default": false,
291 "description": "Start verifier gates as background shell jobs and return task_ids immediately. Use for long build/test/lint gates; completion is tracked in task/status state, and `Bash` with action 'wait' / task_shell_wait are only for early output, final output, or true dependency barriers."
292 },
293 "cwd": {
294 "type": "string",
295 "description": "Optional working directory, relative to the workspace, to detect projects and run gates in. Per-command cwd stays relative to it. Must exist inside the workspace."
296 }
297 },
298 "additionalProperties": false
299 })
300 }
301
302 fn capabilities(&self) -> Vec<ToolCapability> {
303 vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable]
304 }
305
306 fn approval_requirement(&self) -> ApprovalRequirement {
307 ApprovalRequirement::Required
308 }
309
310 fn starts_detached_for(&self, input: &Value) -> bool {
311 input.get("background").and_then(Value::as_bool) == Some(true)
312 }
313
314 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
315 crate::core::engine::tool_catalog::enforce_tool_denial(context, self.name(), &input)?;
316 let input: RunVerifiersInput = serde_json::from_value(input)
317 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
318 // `cwd` scopes the whole call — project detection, gate roots, and
319 // reported paths — to an existing in-workspace subdirectory.
320 let scoped;
321 let context = match input
322 .cwd
323 .as_deref()
324 .map(str::trim)
325 .filter(|cwd| !cwd.is_empty())
326 {
327 None => context,
328 Some(raw) => {
329 let root = context.resolve_existing_dir(raw, "cwd")?;
330 let mut narrowed = context.clone();
331 narrowed.workspace = root;
332 scoped = narrowed;
333 &scoped
334 }
335 };
336 let profile = VerifierProfile::parse(input.profile.as_str())?;
337 let level = VerifierLevel::parse(input.level.as_str())?;
338 if input.max_python_files == 0 || input.max_python_files > 1000 {
339 return Err(ToolError::invalid_input(
340 "max_python_files must be between 1 and 1000",
341 ));
342 }
343 if input.commands.len() > MAX_CUSTOM_GATES {
344 return Err(ToolError::invalid_input(format!(
345 "commands may contain at most {MAX_CUSTOM_GATES} custom gates"
346 )));
347 }
348
349 let gates = build_gate_plan(
350 context,
351 profile,
352 level,
353 input.max_python_files,
354 &input.commands,
355 )?;
356 if gates.is_empty() {
357 let verifier_verdict = VerifierVerdict::from_counts(0, 0, 0);
358 let output = RunVerifiersOutput {
359 success: false,
360 profile: profile.as_str().to_string(),
361 level: level.as_str().to_string(),
362 workspace: context.workspace.display().to_string(),
363 gate_count: 0,
364 passed: 0,
365 failed: 0,
366 skipped: 0,
367 verifier_verdict,
368 summary: "No verifier gates were detected. Provide custom commands or choose a profile that matches this workspace.".to_string(),
369 gates: Vec::new(),
370 };
371 return verifier_tool_result(&output);
372 }
373
374 if input.background {
375 return start_background_gates(context, profile, level, gates);
376 }
377
378 let mut handles = Vec::with_capacity(gates.len());
379 for gate in gates {
380 handles.push(tokio::task::spawn_blocking(move || run_gate(gate)));
381 }
382
383 let mut results = Vec::with_capacity(handles.len());
384 for handle in handles {
385 match handle.await {
386 Ok(result) => results.push(result),
387 Err(err) => results.push(GateResult {
388 name: "internal-join".to_string(),
389 ecosystem: "internal".to_string(),
390 status: GateStatus::Failed,
391 command: "tokio::task::spawn_blocking".to_string(),
392 cwd: context.workspace.display().to_string(),
393 exit_code: None,
394 duration_ms: 0,
395 stdout: String::new(),
396 stderr: format!("Verifier task join failed: {err}"),
397 stdout_truncated: false,
398 stderr_truncated: false,
399 skipped_reason: None,
400 }),
401 }
402 }
403 results.sort_by(|a, b| a.name.cmp(&b.name));
404
405 let passed = results
406 .iter()
407 .filter(|result| result.status == GateStatus::Passed)
408 .count();
409 let failed = results
410 .iter()
411 .filter(|result| result.status == GateStatus::Failed)
412 .count();
413 let skipped = results
414 .iter()
415 .filter(|result| result.status == GateStatus::Skipped)
416 .count();
417 let success = failed == 0 && skipped == 0;
418 let verifier_verdict = VerifierVerdict::from_counts(results.len(), failed, skipped);
419 let summary = if success {
420 format!("All {passed} verifier gates passed.")
421 } else {
422 format!("{passed} passed, {failed} failed, {skipped} skipped.")
423 };
424
425 let output = RunVerifiersOutput {
426 success,
427 profile: profile.as_str().to_string(),
428 level: level.as_str().to_string(),
429 workspace: context.workspace.display().to_string(),
430 gate_count: results.len(),
431 passed,
432 failed,
433 skipped,
434 verifier_verdict,
435 summary,
436 gates: results,
437 };
438
439 verifier_tool_result(&output)
440 }
441 }
442
443 /// Run quick auto verifier gates after a successful workflow completion (#4013).
444 pub(crate) async fn run_workflow_completion_gates(
445 context: &ToolContext,
446 ) -> Result<Value, ToolError> {
447 let gates = build_gate_plan(
448 context,
449 VerifierProfile::Auto,
450 VerifierLevel::Quick,
451 DEFAULT_MAX_PYTHON_FILES,
452 &[],
453 )?;
454 if gates.is_empty() {
455 return Ok(json!({
456 "success": false,
457 "profile": "auto",
458 "level": "quick",
459 "gate_count": 0,
460 "summary": "No verifier gates detected for this workspace.",
461 "gates": [],
462 }));
463 }
464
465 let workspace = context.workspace.display().to_string();
466 let mut handles = Vec::with_capacity(gates.len());
467 for gate in gates {
468 handles.push(tokio::task::spawn_blocking(move || run_gate(gate)));
469 }
470
471 let mut results = Vec::with_capacity(handles.len());
472 for handle in handles {
473 match handle.await {
474 Ok(result) => results.push(result),
475 Err(err) => results.push(GateResult {
476 name: "internal-join".to_string(),
477 ecosystem: "internal".to_string(),
478 status: GateStatus::Failed,
479 command: "tokio::task::spawn_blocking".to_string(),
480 cwd: workspace.clone(),
481 exit_code: None,
482 duration_ms: 0,
483 stdout: String::new(),
484 stderr: format!("Verifier task join failed: {err}"),
485 stdout_truncated: false,
486 stderr_truncated: false,
487 skipped_reason: None,
488 }),
489 }
490 }
491 results.sort_by(|a, b| a.name.cmp(&b.name));
492
493 let passed = results
494 .iter()
495 .filter(|result| result.status == GateStatus::Passed)
496 .count();
497 let failed = results
498 .iter()
499 .filter(|result| result.status == GateStatus::Failed)
500 .count();
501 let skipped = results
502 .iter()
503 .filter(|result| result.status == GateStatus::Skipped)
504 .count();
505 let success = failed == 0 && skipped == 0;
506 if !success {
507 return Err(ToolError::execution_failed(format!(
508 "{passed} passed, {failed} failed, {skipped} skipped"
509 )));
510 }
511 Ok(json!({
512 "success": true,
513 "profile": "auto",
514 "level": "quick",
515 "gate_count": results.len(),
516 "passed": passed,
517 "failed": failed,
518 "skipped": skipped,
519 "summary": format!("All {passed} verifier gates passed."),
520 "gates": results,
521 }))
522 }
523
524 fn verifier_tool_result(output: &RunVerifiersOutput) -> Result<ToolResult, ToolError> {
525 ToolResult::json(output)
526 .map_err(|err| ToolError::execution_failed(err.to_string()))
527 .map(|result| {
528 result.with_metadata(json!({
529 "verifier_verdict": output.verifier_verdict,
530 }))
531 })
532 }
533
534 fn start_background_gates(
535 context: &ToolContext,
536 profile: VerifierProfile,
537 level: VerifierLevel,
538 gates: Vec<VerifierGate>,
539 ) -> Result<ToolResult, ToolError> {
540 let mut jobs = Vec::with_capacity(gates.len());
541 let mut started = 0usize;
542 let mut skipped = 0usize;
543 let mut failed_to_start = 0usize;
544
545 for gate in gates {
546 let cwd = gate.cwd.display().to_string();
547 let Some(program) = gate.program.as_deref() else {
548 skipped += 1;
549 jobs.push(BackgroundGateJob {
550 name: gate.name,
551 ecosystem: gate.ecosystem,
552 status: "skipped".to_string(),
553 command: String::new(),
554 cwd,
555 task_id: None,
556 skipped_reason: gate.skipped_reason,
557 error: None,
558 });
559 continue;
560 };
561
562 let command = render_gate_command(program, &gate.args)?;
563 let env: HashMap<String, String> = gate.env.into_iter().collect();
564 let spawn_result = {
565 let mut manager = context
566 .shell_manager
567 .lock()
568 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
569 manager.execute_with_options_env_for_session(
570 &command,
571 Some(&cwd),
572 BACKGROUND_GATE_TIMEOUT_MS,
573 true,
574 None,
575 false,
576 context.elevated_sandbox_policy.clone(),
577 env,
578 &context.state_namespace,
579 )
580 };
581
582 match spawn_result {
583 Ok(result) => {
584 started += 1;
585 jobs.push(BackgroundGateJob {
586 name: gate.name,
587 ecosystem: gate.ecosystem,
588 status: "running".to_string(),
589 command,
590 cwd,
591 task_id: result.task_id,
592 skipped_reason: None,
593 error: None,
594 });
595 }
596 Err(err) => {
597 failed_to_start += 1;
598 jobs.push(BackgroundGateJob {
599 name: gate.name,
600 ecosystem: gate.ecosystem,
601 status: "failed_to_start".to_string(),
602 command,
603 cwd,
604 task_id: None,
605 skipped_reason: None,
606 error: Some(err.to_string()),
607 });
608 }
609 }
610 }
611
612 jobs.sort_by(|a, b| a.name.cmp(&b.name));
613 let success = failed_to_start == 0 && started > 0;
614 let summary = if failed_to_start == 0 {
615 format!(
616 "Started {started} verifier gate(s) in the background; {skipped} skipped. Completion is tracked in task/status state. Continue inspecting or implementing while they run."
617 )
618 } else {
619 format!(
620 "Started {started} verifier gate(s), failed to start {failed_to_start}, and skipped {skipped}. Completion is tracked in task/status state. Continue inspecting or implementing while they run."
621 )
622 };
623 let task_ids = jobs
624 .iter()
625 .filter_map(|job| job.task_id.clone())
626 .collect::<Vec<_>>();
627 let output = RunVerifiersBackgroundOutput {
628 success,
629 profile: profile.as_str().to_string(),
630 level: level.as_str().to_string(),
631 workspace: context.workspace.display().to_string(),
632 background: true,
633 gate_count: jobs.len(),
634 started,
635 skipped,
636 failed_to_start,
637 summary,
638 jobs,
639 };
640
641 let mut result =
642 ToolResult::json(&output).map_err(|err| ToolError::execution_failed(err.to_string()))?;
643 result.success = success;
644 Ok(result.with_metadata(json!({
645 "backgrounded": true,
646 "detached_start": true,
647 "verifier_background": true,
648 "auto_resume_on_completion": false,
649 "completion_surface": "task_status",
650 "background_policy": "nonblocking",
651 "task_ids": task_ids,
652 "poll_with": ["task_shell_wait"]
653 })))
654 }
655
656 fn render_gate_command(program: &str, args: &[String]) -> Result<String, ToolError> {
657 try_join(std::iter::once(program).chain(args.iter().map(String::as_str)))
658 .map_err(|err| ToolError::execution_failed(format!("failed to render gate command: {err}")))
659 }
660
661 fn build_gate_plan(
662 context: &ToolContext,
663 profile: VerifierProfile,
664 level: VerifierLevel,
665 max_python_files: usize,
666 custom_commands: &[CustomVerifierInput],
667 ) -> Result<Vec<VerifierGate>, ToolError> {
668 let workspace = &context.workspace;
669 let mut gates = Vec::new();
670
671 if profile == VerifierProfile::Auto && workspace.join(".git").exists() {
672 gates.push(gate(
673 "git-whitespace",
674 "git",
675 workspace,
676 "git",
677 ["diff", "--check"],
678 ));
679 }
680
681 if profile_matches(profile, VerifierProfile::Rust) && workspace.join("Cargo.toml").exists() {
682 add_rust_gates(&mut gates, workspace, level);
683 }
684 if profile_matches(profile, VerifierProfile::Node) && workspace.join("package.json").exists() {
685 add_node_gates(&mut gates, workspace, level);
686 }
687 if profile_matches(profile, VerifierProfile::Python) && has_python_project(workspace) {
688 add_python_gates(&mut gates, workspace, level, max_python_files);
689 }
690 if profile_matches(profile, VerifierProfile::Go) && workspace.join("go.mod").exists() {
691 add_go_gates(&mut gates, workspace, level);
692 }
693
694 for custom in custom_commands {
695 gates.push(custom_gate(context, custom)?);
696 }
697
698 Ok(gates)
699 }
700
701 fn profile_matches(selected: VerifierProfile, candidate: VerifierProfile) -> bool {
702 selected == VerifierProfile::Auto || selected == candidate
703 }
704
705 fn add_rust_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
706 let locked = workspace.join("Cargo.lock").exists();
707 gates.push(gate(
708 "rust-fmt",
709 "rust",
710 workspace,
711 "cargo",
712 ["fmt", "--all", "--", "--check"],
713 ));
714
715 let metadata_args = if locked {
716 vec!["metadata", "--locked", "--format-version", "1", "--no-deps"]
717 } else {
718 vec!["metadata", "--format-version", "1", "--no-deps"]
719 };
720 gates.push(gate_vec(
721 "rust-metadata",
722 "rust",
723 workspace,
724 "cargo",
725 metadata_args,
726 ));
727
728 let mut check_args = vec!["check", "--workspace", "--all-targets"];
729 if locked {
730 check_args.push("--locked");
731 }
732 gates.push(gate_vec(
733 "rust-check",
734 "rust",
735 workspace,
736 "cargo",
737 check_args,
738 ));
739
740 if level == VerifierLevel::Full {
741 let mut clippy_args = vec!["clippy", "--workspace", "--all-targets", "--all-features"];
742 if locked {
743 clippy_args.push("--locked");
744 }
745 clippy_args.extend(["--", "-D", "warnings"]);
746 gates.push(gate_vec(
747 "rust-clippy",
748 "rust",
749 workspace,
750 "cargo",
751 clippy_args,
752 ));
753
754 let mut test_args = vec!["test", "--workspace", "--all-features"];
755 if locked {
756 test_args.push("--locked");
757 }
758 gates.push(gate_vec("rust-test", "rust", workspace, "cargo", test_args));
759 }
760 }
761
762 fn add_node_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
763 let scripts = package_json_scripts(workspace);
764 let Some(scripts) = scripts else {
765 gates.push(skipped_gate(
766 "node-package-json",
767 "node",
768 workspace,
769 "package.json is missing or could not be parsed",
770 ));
771 return;
772 };
773 let package_manager = detect_node_package_manager(workspace);
774 for script in ["format:check", "check", "typecheck", "lint"] {
775 if has_meaningful_script(&scripts, script) {
776 gates.push(node_script_gate(workspace, &package_manager, script));
777 }
778 }
779 if level == VerifierLevel::Full && has_meaningful_script(&scripts, "test") {
780 gates.push(node_script_gate(workspace, &package_manager, "test"));
781 }
782 }
783
784 fn add_python_gates(
785 gates: &mut Vec<VerifierGate>,
786 workspace: &Path,
787 level: VerifierLevel,
788 max_python_files: usize,
789 ) {
790 let python_files = collect_python_files(workspace, max_python_files);
791 match python_files {
792 PythonFiles::Files(files) if !files.is_empty() => {
793 gates.push(python_syntax_gate(workspace, &files));
794 }
795 PythonFiles::TooMany { limit, found } => gates.push(skipped_gate(
796 "python-syntax",
797 "python",
798 workspace,
799 format!(
800 "found more than {limit} Python files ({found}); raise max_python_files to verify them"
801 ),
802 )),
803 PythonFiles::Files(_) => {}
804 }
805
806 if level == VerifierLevel::Full && has_pytest_signal(workspace) {
807 gates.push(python_module_gate(
808 "python-pytest",
809 workspace,
810 ["-m", "pytest"],
811 ));
812 }
813 }
814
815 fn add_go_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
816 gates.push(gate("go-test", "go", workspace, "go", ["test", "./..."]));
817 if level == VerifierLevel::Full {
818 gates.push(gate("go-vet", "go", workspace, "go", ["vet", "./..."]));
819 }
820 }
821
822 fn gate<const N: usize>(
823 name: &str,
824 ecosystem: &str,
825 cwd: &Path,
826 program: &str,
827 args: [&str; N],
828 ) -> VerifierGate {
829 gate_vec(name, ecosystem, cwd, program, args)
830 }
831
832 fn gate_vec<I, S>(name: &str, ecosystem: &str, cwd: &Path, program: &str, args: I) -> VerifierGate
833 where
834 I: IntoIterator<Item = S>,
835 S: AsRef<str>,
836 {
837 VerifierGate {
838 name: name.to_string(),
839 ecosystem: ecosystem.to_string(),
840 cwd: cwd.to_path_buf(),
841 program: Some(program.to_string()),
842 args: args
843 .into_iter()
844 .map(|arg| arg.as_ref().to_string())
845 .collect(),
846 env: Vec::new(),
847 skipped_reason: None,
848 }
849 }
850
851 fn skipped_gate(
852 name: &str,
853 ecosystem: &str,
854 cwd: &Path,
855 reason: impl Into<String>,
856 ) -> VerifierGate {
857 VerifierGate {
858 name: name.to_string(),
859 ecosystem: ecosystem.to_string(),
860 cwd: cwd.to_path_buf(),
861 program: None,
862 args: Vec::new(),
863 env: Vec::new(),
864 skipped_reason: Some(reason.into()),
865 }
866 }
867
868 fn custom_gate(
869 context: &ToolContext,
870 custom: &CustomVerifierInput,
871 ) -> Result<VerifierGate, ToolError> {
872 if custom.name.trim().is_empty() {
873 return Err(ToolError::invalid_input(
874 "Custom verifier command is missing 'name'",
875 ));
876 }
877 if custom.program.trim().is_empty() {
878 return Err(ToolError::invalid_input(format!(
879 "Custom verifier '{}' is missing 'program'",
880 custom.name
881 )));
882 }
883 let cwd = match custom.cwd.as_deref() {
884 Some(raw) if !raw.trim().is_empty() => context.resolve_path(raw)?,
885 _ => context.workspace.clone(),
886 };
887 Ok(VerifierGate {
888 name: custom.name.clone(),
889 ecosystem: "custom".to_string(),
890 cwd,
891 program: Some(custom.program.clone()),
892 args: custom.args.clone(),
893 env: Vec::new(),
894 skipped_reason: None,
895 })
896 }
897
898 fn node_script_gate(
899 workspace: &Path,
900 package_manager: &NodePackageManager,
901 script: &str,
902 ) -> VerifierGate {
903 let (program, args) = package_manager.command_for_script(script);
904 gate_vec(&format!("node-{script}"), "node", workspace, program, args)
905 }
906
907 fn python_syntax_gate(workspace: &Path, files: &[PathBuf]) -> VerifierGate {
908 let Some((program, mut args)) = python_command_parts() else {
909 return skipped_gate(
910 "python-syntax",
911 "python",
912 workspace,
913 "Python interpreter is not installed or not in PATH",
914 );
915 };
916 args.push("-c".to_string());
917 args.push(PYTHON_SYNTAX_SCRIPT.to_string());
918 args.extend(files.iter().map(|path| path.display().to_string()));
919 let mut gate = gate_vec("python-syntax", "python", workspace, &program, args);
920 gate.env
921 .push(("PYTHONDONTWRITEBYTECODE".to_string(), "1".to_string()));
922 gate
923 }
924
925 fn python_module_gate<const N: usize>(
926 name: &str,
927 workspace: &Path,
928 module_args: [&str; N],
929 ) -> VerifierGate {
930 let Some((program, mut args)) = python_command_parts() else {
931 return skipped_gate(
932 name,
933 "python",
934 workspace,
935 "Python interpreter is not installed or not in PATH",
936 );
937 };
938 args.extend(module_args.into_iter().map(str::to_string));
939 gate_vec(name, "python", workspace, &program, args)
940 }
941
942 fn python_command_parts() -> Option<(String, Vec<String>)> {
943 let spec = crate::dependencies::Python::resolve()?;
944 Some(crate::dependencies::split_interpreter_spec(&spec))
945 }
946
947 const PYTHON_SYNTAX_SCRIPT: &str = r#"
948 import ast
949 import pathlib
950 import sys
951
952 failures = []
953 for raw in sys.argv[1:]:
954 path = pathlib.Path(raw)
955 try:
956 source = path.read_text(encoding="utf-8")
957 ast.parse(source, filename=raw)
958 except Exception as exc:
959 failures.append(f"{raw}: {exc.__class__.__name__}: {exc}")
960
961 if failures:
962 print("\n".join(failures), file=sys.stderr)
963 sys.exit(1)
964
965 print(f"parsed {len(sys.argv) - 1} Python file(s)")
966 "#;
967
968 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
969 enum NodePackageManager {
970 Npm,
971 Pnpm,
972 Yarn,
973 Bun,
974 }
975
976 impl NodePackageManager {
977 fn command_for_script(self, script: &str) -> (&'static str, Vec<String>) {
978 match self {
979 Self::Npm => ("npm", vec!["run".to_string(), script.to_string()]),
980 Self::Pnpm => ("pnpm", vec!["run".to_string(), script.to_string()]),
981 Self::Yarn => ("yarn", vec!["run".to_string(), script.to_string()]),
982 Self::Bun => ("bun", vec!["run".to_string(), script.to_string()]),
983 }
984 }
985 }
986
987 fn detect_node_package_manager(workspace: &Path) -> NodePackageManager {
988 if workspace.join("pnpm-lock.yaml").exists() {
989 NodePackageManager::Pnpm
990 } else if workspace.join("yarn.lock").exists() {
991 NodePackageManager::Yarn
992 } else if workspace.join("bun.lock").exists() || workspace.join("bun.lockb").exists() {
993 NodePackageManager::Bun
994 } else {
995 NodePackageManager::Npm
996 }
997 }
998
999 fn package_json_scripts(workspace: &Path) -> Option<HashMap<String, String>> {
1000 let raw = fs::read_to_string(workspace.join("package.json")).ok()?;
1001 let parsed = serde_json::from_str::<serde_json::Value>(&raw).ok()?;
1002 let scripts = parsed.get("scripts")?.as_object()?;
1003 Some(
1004 scripts
1005 .iter()
1006 .filter_map(|(key, value)| {
1007 value
1008 .as_str()
1009 .map(|script| (key.clone(), script.to_string()))
1010 })
1011 .collect(),
1012 )
1013 }
1014
1015 fn has_meaningful_script(scripts: &HashMap<String, String>, name: &str) -> bool {
1016 let Some(script) = scripts.get(name).map(|value| value.trim()) else {
1017 return false;
1018 };
1019 !(script.is_empty()
1020 || name == "test"
1021 && script.contains("Error: no test specified")
1022 && script.contains("exit 1"))
1023 }
1024
1025 fn has_python_project(workspace: &Path) -> bool {
1026 workspace.join("pyproject.toml").exists()
1027 || workspace.join("setup.py").exists()
1028 || workspace.join("setup.cfg").exists()
1029 || workspace.join("requirements.txt").exists()
1030 || match collect_python_files(workspace, 1) {
1031 PythonFiles::Files(files) => !files.is_empty(),
1032 PythonFiles::TooMany { .. } => true,
1033 }
1034 }
1035
1036 fn has_pytest_signal(workspace: &Path) -> bool {
1037 if workspace.join("pytest.ini").exists()
1038 || workspace.join("tox.ini").exists()
1039 || workspace.join("tests").is_dir()
1040 {
1041 return true;
1042 }
1043 let pyproject = workspace.join("pyproject.toml");
1044 fs::read_to_string(pyproject)
1045 .map(|raw| raw.contains("pytest") || raw.contains("[tool.pytest"))
1046 .unwrap_or(false)
1047 }
1048
1049 #[derive(Debug, Clone, PartialEq, Eq)]
1050 enum PythonFiles {
1051 Files(Vec<PathBuf>),
1052 TooMany { limit: usize, found: usize },
1053 }
1054
1055 fn collect_python_files(workspace: &Path, limit: usize) -> PythonFiles {
1056 let mut files = BTreeSet::new();
1057 collect_python_files_inner(workspace, workspace, limit, &mut files);
1058 let found = files.len();
1059 if found > limit {
1060 PythonFiles::TooMany { limit, found }
1061 } else {
1062 PythonFiles::Files(files.into_iter().collect())
1063 }
1064 }
1065
1066 fn collect_python_files_inner(
1067 root: &Path,
1068 dir: &Path,
1069 limit: usize,
1070 files: &mut BTreeSet<PathBuf>,
1071 ) {
1072 if files.len() > limit {
1073 return;
1074 }
1075 let Ok(entries) = fs::read_dir(dir) else {
1076 return;
1077 };
1078 for entry in entries.flatten() {
1079 if files.len() > limit {
1080 return;
1081 }
1082 let path = entry.path();
1083 let name = entry.file_name();
1084 if path.is_dir() {
1085 if should_skip_dir_name(&name.to_string_lossy()) {
1086 continue;
1087 }
1088 collect_python_files_inner(root, &path, limit, files);
1089 } else if path.extension().and_then(|ext| ext.to_str()) == Some("py")
1090 && let Ok(relative) = path.strip_prefix(root)
1091 {
1092 files.insert(relative.to_path_buf());
1093 }
1094 }
1095 }
1096
1097 fn should_skip_dir_name(name: &str) -> bool {
1098 matches!(
1099 name,
1100 ".git"
1101 | ".hg"
1102 | ".svn"
1103 | ".venv"
1104 | "venv"
1105 | "env"
1106 | "__pycache__"
1107 | ".mypy_cache"
1108 | ".pytest_cache"
1109 | ".tox"
1110 | "node_modules"
1111 | "target"
1112 | "dist"
1113 | "build"
1114 )
1115 }
1116
1117 fn run_gate(gate: VerifierGate) -> GateResult {
1118 let command = render_command(gate.program.as_deref(), &gate.args);
1119 if let Some(reason) = gate.skipped_reason {
1120 return GateResult {
1121 name: gate.name,
1122 ecosystem: gate.ecosystem,
1123 status: GateStatus::Skipped,
1124 command,
1125 cwd: gate.cwd.display().to_string(),
1126 exit_code: None,
1127 duration_ms: 0,
1128 stdout: String::new(),
1129 stderr: String::new(),
1130 stdout_truncated: false,
1131 stderr_truncated: false,
1132 skipped_reason: Some(reason),
1133 };
1134 }
1135
1136 let Some(program) = gate.program else {
1137 return GateResult {
1138 name: gate.name,
1139 ecosystem: gate.ecosystem,
1140 status: GateStatus::Skipped,
1141 command,
1142 cwd: gate.cwd.display().to_string(),
1143 exit_code: None,
1144 duration_ms: 0,
1145 stdout: String::new(),
1146 stderr: String::new(),
1147 stdout_truncated: false,
1148 stderr_truncated: false,
1149 skipped_reason: Some("verifier has no executable program".to_string()),
1150 };
1151 };
1152
1153 let started = Instant::now();
1154 let mut cmd = Command::new(&program);
1155 cmd.args(&gate.args)
1156 .current_dir(&gate.cwd)
1157 .stdout(Stdio::piped())
1158 .stderr(Stdio::piped());
1159 for (key, value) in &gate.env {
1160 cmd.env(key, value);
1161 }
1162
1163 let output = match cmd.output() {
1164 Ok(output) => output,
1165 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1166 return GateResult {
1167 name: gate.name,
1168 ecosystem: gate.ecosystem,
1169 status: GateStatus::Skipped,
1170 command,
1171 cwd: gate.cwd.display().to_string(),
1172 exit_code: None,
1173 duration_ms: started.elapsed().as_millis() as u64,
1174 stdout: String::new(),
1175 stderr: String::new(),
1176 stdout_truncated: false,
1177 stderr_truncated: false,
1178 skipped_reason: Some(format!("{program} is not installed or not in PATH")),
1179 };
1180 }
1181 Err(err) => {
1182 return GateResult {
1183 name: gate.name,
1184 ecosystem: gate.ecosystem,
1185 status: GateStatus::Failed,
1186 command,
1187 cwd: gate.cwd.display().to_string(),
1188 exit_code: None,
1189 duration_ms: started.elapsed().as_millis() as u64,
1190 stdout: String::new(),
1191 stderr: format!("Failed to spawn verifier: {err}"),
1192 stdout_truncated: false,
1193 stderr_truncated: false,
1194 skipped_reason: None,
1195 };
1196 }
1197 };
1198
1199 let (stdout, stdout_truncated) = truncate_with_note(
1200 &String::from_utf8_lossy(&output.stdout),
1201 MAX_GATE_OUTPUT_CHARS,
1202 );
1203 let (stderr, stderr_truncated) = truncate_with_note(
1204 &String::from_utf8_lossy(&output.stderr),
1205 MAX_GATE_OUTPUT_CHARS,
1206 );
1207 GateResult {
1208 name: gate.name,
1209 ecosystem: gate.ecosystem,
1210 status: if output.status.success() {
1211 GateStatus::Passed
1212 } else {
1213 GateStatus::Failed
1214 },
1215 command,
1216 cwd: gate.cwd.display().to_string(),
1217 exit_code: output.status.code(),
1218 duration_ms: started.elapsed().as_millis() as u64,
1219 stdout,
1220 stderr,
1221 stdout_truncated,
1222 stderr_truncated,
1223 skipped_reason: None,
1224 }
1225 }
1226
1227 fn render_command(program: Option<&str>, args: &[String]) -> String {
1228 let mut parts = Vec::new();
1229 parts.push(program.unwrap_or("<unavailable>").to_string());
1230 parts.extend(args.iter().cloned());
1231 parts.join(" ")
1232 }
1233
1234 fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool) {
1235 if text.chars().count() <= max_chars {
1236 return (text.to_string(), false);
1237 }
1238 let end = char_boundary_index(text, max_chars);
1239 let truncated = &text[..end];
1240 let omitted_chars = text
1241 .chars()
1242 .count()
1243 .saturating_sub(truncated.chars().count());
1244 (
1245 format!(
1246 "{truncated}\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
1247 ),
1248 true,
1249 )
1250 }
1251
1252 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
1253 if max_chars == 0 {
1254 return 0;
1255 }
1256 for (count, (idx, _)) in text.char_indices().enumerate() {
1257 if count == max_chars {
1258 return idx;
1259 }
1260 }
1261 text.len()
1262 }
1263
1264 #[cfg(test)]
1265 mod tests {
1266 use super::*;
1267 use crate::tools::shell::ShellStatus;
1268 use std::time::Duration;
1269 use tempfile::tempdir;
1270
1271 const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000;
1272
1273 fn wait_for_completed_shell(
1274 manager: &mut crate::tools::shell::ShellManager,
1275 task_id: &str,
1276 ) -> crate::tools::shell::ShellResult {
1277 let deadline = Instant::now() + Duration::from_millis(BACKGROUND_COMPLETION_WAIT_MS);
1278
1279 loop {
1280 let result = manager
1281 .get_output(task_id, true, 1_000)
1282 .expect("background output");
1283 if result.status != ShellStatus::Running || Instant::now() >= deadline {
1284 return result;
1285 }
1286 std::thread::sleep(Duration::from_millis(50));
1287 }
1288 }
1289
1290 #[test]
1291 fn run_verifiers_requires_user_approval() {
1292 let tool = RunVerifiersTool;
1293 assert_eq!(
1294 tool.approval_requirement(),
1295 ApprovalRequirement::Required,
1296 "run_verifiers executes project code and must require approval"
1297 );
1298 }
1299
1300 #[test]
1301 fn run_verifiers_background_advertises_detached_start() {
1302 let tool = RunVerifiersTool;
1303 let schema = tool.input_schema();
1304 let background_description = schema["properties"]["background"]["description"]
1305 .as_str()
1306 .expect("background description");
1307
1308 assert!(background_description.contains("Bash"));
1309 assert!(background_description.contains("task_shell_wait"));
1310 assert!(
1311 !background_description.contains("exec_shell"),
1312 "live descriptions must not teach the retired exec_shell name"
1313 );
1314 assert!(tool.starts_detached_for(&json!({"background": true})));
1315 assert!(!tool.starts_detached_for(&json!({"profile": "auto"})));
1316 }
1317
1318 #[test]
1319 fn auto_profile_detects_multiple_ecosystems_without_bash() {
1320 let tmp = tempdir().expect("tempdir");
1321 fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").expect("cargo manifest");
1322 fs::write(
1323 tmp.path().join("package.json"),
1324 r#"{"scripts":{"lint":"eslint .","test":"echo ok"}}"#,
1325 )
1326 .expect("package json");
1327 fs::write(tmp.path().join("main.py"), "print('ok')\n").expect("python file");
1328 fs::write(tmp.path().join("go.mod"), "module example.com/app\n").expect("go mod");
1329
1330 let ctx = ToolContext::new(tmp.path());
1331 let gates = build_gate_plan(
1332 &ctx,
1333 VerifierProfile::Auto,
1334 VerifierLevel::Quick,
1335 DEFAULT_MAX_PYTHON_FILES,
1336 &[],
1337 )
1338 .expect("plan");
1339 let names: BTreeSet<&str> = gates.iter().map(|gate| gate.name.as_str()).collect();
1340
1341 assert!(names.contains("rust-fmt"));
1342 assert!(names.contains("node-lint"));
1343 assert!(names.contains("python-syntax"));
1344 assert!(names.contains("go-test"));
1345 assert!(
1346 gates
1347 .iter()
1348 .filter_map(|gate| gate.program.as_deref())
1349 .all(|program| program != "bash"),
1350 "built-in verifier gates must not require bash"
1351 );
1352 }
1353
1354 #[test]
1355 fn custom_commands_can_choose_bash_explicitly() {
1356 let tmp = tempdir().expect("tempdir");
1357 let ctx = ToolContext::new(tmp.path());
1358 let custom = CustomVerifierInput {
1359 name: "shell-check".to_string(),
1360 program: "bash".to_string(),
1361 args: vec!["-lc".to_string(), "echo ok".to_string()],
1362 cwd: None,
1363 };
1364
1365 let gate = custom_gate(&ctx, &custom).expect("custom gate");
1366
1367 assert_eq!(gate.program.as_deref(), Some("bash"));
1368 assert_eq!(gate.args, vec!["-lc", "echo ok"]);
1369 }
1370
1371 #[test]
1372 fn node_default_npm_init_test_script_is_not_a_verifier() {
1373 let mut scripts = HashMap::new();
1374 scripts.insert(
1375 "test".to_string(),
1376 "echo \"Error: no test specified\" && exit 1".to_string(),
1377 );
1378
1379 assert!(!has_meaningful_script(&scripts, "test"));
1380 }
1381
1382 #[tokio::test]
1383 async fn run_verifiers_executes_custom_direct_command() {
1384 if !crate::dependencies::RustC::available() {
1385 return;
1386 }
1387 let tmp = tempdir().expect("tempdir");
1388 let ctx = ToolContext::new(tmp.path());
1389 let tool = RunVerifiersTool;
1390 let result = tool
1391 .execute(
1392 json!({
1393 "profile": "auto",
1394 "commands": [
1395 {
1396 "name": "rustc-version",
1397 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1398 "args": ["--version"]
1399 }
1400 ]
1401 }),
1402 &ctx,
1403 )
1404 .await
1405 .expect("execute");
1406
1407 let parsed: RunVerifiersOutput =
1408 serde_json::from_str(&result.content).expect("verifier output json");
1409 assert!(parsed.success, "result: {}", result.content);
1410 assert_eq!(parsed.passed, 1);
1411 assert_eq!(parsed.failed, 0);
1412 assert_eq!(parsed.skipped, 0);
1413 assert!(
1414 parsed.gates[0].stdout.contains("rustc"),
1415 "stdout should include rustc version: {:?}",
1416 parsed.gates[0].stdout
1417 );
1418 }
1419
1420 #[tokio::test]
1421 async fn run_verifiers_emits_verdict_mapping() {
1422 let tmp = tempdir().expect("tempdir");
1423 let ctx = ToolContext::new(tmp.path());
1424 let tool = RunVerifiersTool;
1425
1426 let partial = tool
1427 .execute(json!({"profile": "auto"}), &ctx)
1428 .await
1429 .expect("execute partial verifier");
1430 assert_verdict(&partial, "partial");
1431
1432 if !crate::dependencies::RustC::available() {
1433 return;
1434 }
1435
1436 let pass = tool
1437 .execute(
1438 json!({
1439 "profile": "auto",
1440 "commands": [
1441 {
1442 "name": "rustc-version",
1443 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1444 "args": ["--version"]
1445 }
1446 ]
1447 }),
1448 &ctx,
1449 )
1450 .await
1451 .expect("execute passing verifier");
1452 assert_verdict(&pass, "pass");
1453
1454 let fail = tool
1455 .execute(
1456 json!({
1457 "profile": "auto",
1458 "commands": [
1459 {
1460 "name": "rustc-bad-flag",
1461 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1462 "args": ["--definitely-not-a-rustc-flag"]
1463 }
1464 ]
1465 }),
1466 &ctx,
1467 )
1468 .await
1469 .expect("execute failing verifier");
1470 assert_verdict(&fail, "fail");
1471 }
1472
1473 #[tokio::test]
1474 async fn run_verifiers_cwd_scopes_detection_to_subdir() {
1475 let tmp = tempdir().expect("tempdir");
1476 let sub = tmp.path().join("nested");
1477 std::fs::create_dir(&sub).expect("subdir");
1478 let ctx = ToolContext::new(tmp.path());
1479
1480 // Empty subdir: no gates detected, and the reported workspace is the
1481 // scoped root rather than the parent workspace.
1482 let result = RunVerifiersTool
1483 .execute(json!({"profile": "auto", "cwd": "nested"}), &ctx)
1484 .await
1485 .expect("cwd-scoped execute");
1486 let parsed: RunVerifiersOutput =
1487 serde_json::from_str(&result.content).expect("verifier output json");
1488 assert_eq!(parsed.gate_count, 0);
1489 assert_eq!(
1490 parsed.workspace,
1491 sub.canonicalize().expect("canonical").display().to_string()
1492 );
1493
1494 // Missing dir: refused with the fallback named.
1495 let err = RunVerifiersTool
1496 .execute(json!({"profile": "auto", "cwd": "no-such-dir"}), &ctx)
1497 .await
1498 .expect_err("missing dir must be refused");
1499 let message = err.to_string();
1500 assert!(message.contains("not an existing directory"), "{message}");
1501 assert!(message.contains("drop `cwd`"), "{message}");
1502 }
1503
1504 fn assert_verdict(result: &ToolResult, verifier: &str) {
1505 let parsed: Value = serde_json::from_str(&result.content).expect("verifier output json");
1506 assert_eq!(parsed["verifier_verdict"], verifier, "{}", result.content);
1507 assert!(parsed.get("hunt_verdict").is_none(), "{}", result.content);
1508 assert!(parsed.get("goal_status").is_none(), "{}", result.content);
1509 let metadata = result.metadata.as_ref().expect("verifier metadata");
1510 assert_eq!(metadata["verifier_verdict"], verifier, "{metadata}");
1511 assert!(metadata.get("hunt_verdict").is_none(), "{metadata}");
1512 assert!(metadata.get("task_updates").is_none(), "{metadata}");
1513 }
1514
1515 #[tokio::test]
1516 async fn run_verifiers_background_starts_shell_jobs_and_returns_task_ids() {
1517 let tmp = tempdir().expect("tempdir");
1518 let ctx = ToolContext::new(tmp.path());
1519 let tool = RunVerifiersTool;
1520 // Unix: drive this very libtest binary with `--list` (no rustup, no
1521 // $HOME). Windows: the background gate is rendered with POSIX
1522 // quoting and handed to PowerShell, which cannot parse a quoted
1523 // absolute path with backslashes, so use a bare `cmd` there — the
1524 // listing shape (`name: test`) is the same either way.
1525 #[cfg(not(windows))]
1526 let (program, args) = (
1527 std::env::current_exe()
1528 .expect("test executable")
1529 .to_string_lossy()
1530 .into_owned(),
1531 vec!["--list".to_string()],
1532 );
1533 #[cfg(windows)]
1534 let (program, args) = (
1535 "cmd".to_string(),
1536 vec!["/c".to_string(), "echo test-list: test".to_string()],
1537 );
1538 let result = tool
1539 .execute(
1540 json!({
1541 "profile": "auto",
1542 "background": true,
1543 "commands": [
1544 {
1545 "name": "test-list",
1546 "program": program,
1547 "args": args
1548 }
1549 ]
1550 }),
1551 &ctx,
1552 )
1553 .await
1554 .expect("execute");
1555
1556 let parsed: RunVerifiersBackgroundOutput =
1557 serde_json::from_str(&result.content).expect("background verifier output json");
1558 assert!(parsed.success, "result: {}", result.content);
1559 assert!(parsed.background);
1560 assert_eq!(parsed.started, 1);
1561 assert_eq!(parsed.failed_to_start, 0);
1562 assert!(parsed.summary.contains("Completion is tracked"));
1563 let task_id = parsed.jobs[0]
1564 .task_id
1565 .as_deref()
1566 .expect("background task id");
1567 let metadata = result.metadata.as_ref().expect("metadata");
1568 assert!(
1569 metadata
1570 .get("verifier_background")
1571 .and_then(Value::as_bool)
1572 .unwrap_or(false),
1573 "metadata should mark verifier background start"
1574 );
1575 assert_eq!(
1576 metadata
1577 .get("auto_notify_on_completion")
1578 .and_then(Value::as_bool),
1579 None
1580 );
1581 assert_eq!(
1582 metadata
1583 .get("auto_resume_on_completion")
1584 .and_then(Value::as_bool),
1585 Some(false)
1586 );
1587 assert_eq!(
1588 metadata.get("completion_surface").and_then(Value::as_str),
1589 Some("task_status")
1590 );
1591 assert_eq!(
1592 metadata.get("background_policy").and_then(Value::as_str),
1593 Some("nonblocking")
1594 );
1595
1596 let output = wait_for_completed_shell(
1597 &mut ctx.shell_manager.lock().expect("shell manager"),
1598 task_id,
1599 );
1600 assert_eq!(
1601 output.status,
1602 ShellStatus::Completed,
1603 "stdout: {:?} stderr: {:?}",
1604 output.stdout,
1605 output.stderr
1606 );
1607 assert!(
1608 output.stdout.lines().any(|line| line.ends_with(": test")),
1609 "stdout should include the test listing: {:?}",
1610 output.stdout
1611 );
1612 }
1613 }
1614
1614 lines RUST