Skip to main content

terraphim_orchestrator/
compound.rs

1use std::path::{Path, PathBuf};
2use std::time::{Duration, Instant};
3
4use tokio::sync::mpsc;
5use tracing::{debug, info, warn};
6use uuid::Uuid;
7
8use terraphim_types::{FindingCategory, FindingSeverity, ReviewAgentOutput, ReviewFinding};
9
10use crate::config::CompoundReviewConfig;
11use crate::error::OrchestratorError;
12use crate::scope::WorktreeManager;
13
14// Embed prompt templates at compile time to avoid CWD-dependent file loading.
15// The ADF binary may run from /opt/ai-dark-factory/ but templates live in the
16// source tree. Embedding eliminates the path resolution issue entirely.
17const PROMPT_SECURITY: &str = include_str!("../prompts/review-security.md");
18const PROMPT_ARCHITECTURE: &str = include_str!("../prompts/review-architecture.md");
19const PROMPT_PERFORMANCE: &str = include_str!("../prompts/review-performance.md");
20const PROMPT_QUALITY: &str = include_str!("../prompts/review-quality.md");
21const PROMPT_DOMAIN: &str = include_str!("../prompts/review-domain.md");
22const PROMPT_DESIGN_QUALITY: &str = include_str!("../prompts/review-design-quality.md");
23
24/// Definition of a single review group (1 agent per group).
25#[derive(Debug, Clone)]
26pub struct ReviewGroupDef {
27    /// Name of the agent (e.g., "security-sentinel").
28    pub agent_name: String,
29    /// Category of findings this agent produces.
30    pub category: FindingCategory,
31    /// LLM tier to use (e.g., "Quick", "Deep").
32    pub llm_tier: String,
33    /// CLI tool to invoke (e.g., "opencode", "claude").
34    pub cli_tool: String,
35    /// Optional model override.
36    pub model: Option<String>,
37    /// Path to prompt template file (retained for logging/debug).
38    pub prompt_template: String,
39    /// Embedded prompt content (compile-time via include_str).
40    pub prompt_content: &'static str,
41    /// Whether this agent only runs on visual/design changes.
42    pub visual_only: bool,
43    /// Persona identity for this review agent (e.g., "Vigil", "Carthos").
44    pub persona: Option<String>,
45}
46
47impl ReviewGroupDef {
48    /// Load the prompt template content from file.
49    pub fn prompt(&self) -> &str {
50        self.prompt_content
51    }
52}
53
54/// Configuration for the review swarm.
55#[derive(Debug, Clone)]
56pub struct SwarmConfig {
57    /// Review group definitions (6 groups).
58    pub groups: Vec<ReviewGroupDef>,
59    /// Timeout for agent execution.
60    pub timeout: Duration,
61    /// Root directory for worktrees.
62    pub worktree_root: PathBuf,
63    /// Path to the git repository.
64    pub repo_path: PathBuf,
65    /// Base branch for comparison.
66    pub base_branch: String,
67    /// Maximum number of concurrent agents.
68    pub max_concurrent_agents: usize,
69    /// Whether to create PRs with findings.
70    pub create_prs: bool,
71}
72
73impl SwarmConfig {
74    /// Create a SwarmConfig from CompoundReviewConfig and add default groups.
75    pub fn from_compound_config(config: &CompoundReviewConfig) -> Self {
76        let mut groups = default_groups();
77
78        // Override cli_tool and model from CompoundReviewConfig when present.
79        if let Some(ref cli_tool) = config.cli_tool {
80            for group in &mut groups {
81                group.cli_tool = cli_tool.clone();
82            }
83        }
84        if let Some(ref model) = config.model {
85            // If provider is also set and CLI is opencode, compose provider/model
86            let composed = if let Some(ref provider) = config.provider {
87                let cli_tool_name = config.cli_tool.as_deref().unwrap_or("");
88                let cli_name = std::path::Path::new(cli_tool_name)
89                    .file_name()
90                    .and_then(|n| n.to_str())
91                    .unwrap_or(cli_tool_name);
92                if cli_name == "opencode" {
93                    format!("{}/{}", provider, model)
94                } else {
95                    model.clone()
96                }
97            } else {
98                model.clone()
99            };
100            for group in &mut groups {
101                group.model = Some(composed.clone());
102            }
103        }
104
105        Self {
106            groups,
107            timeout: Duration::from_secs(config.max_duration_secs),
108            worktree_root: config.worktree_root.clone(),
109            repo_path: config.repo_path.clone(),
110            base_branch: config.base_branch.clone(),
111            max_concurrent_agents: config.max_concurrent_agents,
112            create_prs: config.create_prs,
113        }
114    }
115
116    /// Create a SwarmConfig from CompoundReviewConfig with no review groups.
117    /// Useful for testing orchestrator lifecycle without spawning agents.
118    pub fn from_compound_config_empty(config: &CompoundReviewConfig) -> Self {
119        Self {
120            groups: vec![],
121            timeout: Duration::from_secs(300),
122            worktree_root: config.worktree_root.clone(),
123            repo_path: config.repo_path.clone(),
124            base_branch: config.base_branch.clone(),
125            max_concurrent_agents: config.max_concurrent_agents,
126            create_prs: config.create_prs,
127        }
128    }
129}
130
131/// Result of a compound review cycle.
132#[derive(Debug, Clone)]
133pub struct CompoundReviewResult {
134    /// Correlation ID for this review run.
135    pub correlation_id: Uuid,
136    /// All findings from all agents (deduplicated).
137    pub findings: Vec<ReviewFinding>,
138    /// Individual agent outputs.
139    pub agent_outputs: Vec<ReviewAgentOutput>,
140    /// Overall pass/fail status.
141    pub pass: bool,
142    /// Duration of the review.
143    pub duration: Duration,
144    /// Number of agents that ran.
145    pub agents_run: usize,
146    /// Number of agents that failed.
147    pub agents_failed: usize,
148}
149
150impl CompoundReviewResult {
151    /// Format a structured markdown summary suitable for posting as a Gitea comment.
152    pub fn format_report(&self) -> String {
153        let verdict = if self.pass { "✅ PASS" } else { "❌ NO-GO" };
154        let duration_secs = self.duration.as_secs();
155
156        let mut report = "## Compound Review\n\n".to_string();
157        report.push_str(&format!(
158            "**Verdict: {}** | Duration: {}s | Agents: {} ({} failed)\n\n",
159            verdict, duration_secs, self.agents_run, self.agents_failed
160        ));
161
162        // Findings table
163        if !self.findings.is_empty() {
164            report.push_str(&format!("### Findings ({})\n\n", self.findings.len()));
165            report.push_str("| Severity | File | Finding | Conf |\n");
166            report.push_str("|----------|------|---------|------|\n");
167            for f in &self.findings {
168                let sev = format!("{:?}", f.severity);
169                let file_loc = if !f.file.is_empty() {
170                    if f.line > 0 {
171                        format!("{}:{}", f.file, f.line)
172                    } else {
173                        f.file.clone()
174                    }
175                } else {
176                    "-".to_string()
177                };
178                // Truncate finding text
179                let finding_text = if f.finding.len() > 120 {
180                    format!("{}...", &f.finding[..117])
181                } else {
182                    f.finding.clone()
183                };
184                report.push_str(&format!(
185                    "| {} | {} | {} | {:.0}% |\n",
186                    sev,
187                    file_loc,
188                    finding_text,
189                    f.confidence * 100.0
190                ));
191            }
192            report.push('\n');
193        } else {
194            report.push_str("**No findings.**\n\n");
195        }
196
197        // Per-agent summary
198        report.push_str("### Per-Agent Summary\n\n");
199        for output in &self.agent_outputs {
200            let status = if output.pass { "✅" } else { "❌" };
201            report.push_str(&format!(
202                "- {} {}: {} finding(s) — {}\n",
203                status,
204                output.agent,
205                output.findings.len(),
206                output.summary
207            ));
208        }
209
210        report
211    }
212
213    /// Extract CRITICAL and HIGH findings suitable for issue filing.
214    pub fn actionable_findings(&self) -> Vec<&ReviewFinding> {
215        self.findings
216            .iter()
217            .filter(|f| {
218                matches!(
219                    f.severity,
220                    FindingSeverity::Critical | FindingSeverity::High
221                )
222            })
223            .collect()
224    }
225}
226
227/// Nightly compound review workflow with 6-agent swarm.
228///
229/// Dispatches review agents in parallel, collects findings,
230/// and optionally creates PRs with results.
231#[derive(Debug)]
232pub struct CompoundReviewWorkflow {
233    config: SwarmConfig,
234    worktree_manager: WorktreeManager,
235}
236
237impl CompoundReviewWorkflow {
238    /// Create a new compound review workflow from swarm config.
239    pub fn new(config: SwarmConfig) -> Self {
240        let worktree_manager = WorktreeManager::with_base(&config.repo_path, &config.worktree_root);
241        Self {
242            config,
243            worktree_manager,
244        }
245    }
246
247    /// Create from CompoundReviewConfig (legacy compatibility).
248    pub fn from_compound_config(config: CompoundReviewConfig) -> Self {
249        let swarm_config = SwarmConfig::from_compound_config(&config);
250        Self::new(swarm_config)
251    }
252
253    /// Run a full compound review cycle.
254    ///
255    /// 1. Get changed files between git_ref and base_ref
256    /// 2. Filter groups based on visual changes
257    /// 3. Spawn agents in parallel
258    /// 4. Collect results with timeout
259    /// 5. Deduplicate findings
260    /// 6. Return structured result
261    pub async fn run(
262        &self,
263        git_ref: &str,
264        base_ref: &str,
265    ) -> Result<CompoundReviewResult, OrchestratorError> {
266        let start = Instant::now();
267        let correlation_id = Uuid::new_v4();
268
269        info!(
270            correlation_id = %correlation_id,
271            git_ref = %git_ref,
272            base_ref = %base_ref,
273            "starting compound review swarm"
274        );
275
276        // Get changed files
277        let changed_files = self.get_changed_files(git_ref, base_ref).await?;
278        debug!(count = changed_files.len(), "found changed files");
279
280        // Filter groups based on visual changes
281        let has_visual = has_visual_changes(&changed_files);
282        let active_groups: Vec<&ReviewGroupDef> = self
283            .config
284            .groups
285            .iter()
286            .filter(|g| !g.visual_only || has_visual)
287            .collect();
288
289        info!(
290            total_groups = self.config.groups.len(),
291            active_groups = active_groups.len(),
292            has_visual_changes = has_visual,
293            "filtered review groups"
294        );
295
296        // Create worktree for this review
297        let worktree_name = format!("review-{}", correlation_id);
298        let worktree_path = self
299            .worktree_manager
300            .create_worktree(&worktree_name, git_ref)
301            .await
302            .map_err(|e| {
303                OrchestratorError::CompoundReviewFailed(format!("failed to create worktree: {}", e))
304            })?;
305
306        // Channel for collecting agent outputs
307        let (tx, mut rx) = mpsc::channel::<AgentResult>(active_groups.len().max(1));
308
309        // Spawn agents in parallel
310        let mut spawned_count = 0;
311        for group in active_groups {
312            let tx = tx.clone();
313            let group = group.clone();
314            let worktree_path = worktree_path.clone();
315            let changed_files = changed_files.clone();
316            let timeout = self.config.timeout;
317            let cli_tool = group.cli_tool.clone();
318
319            tokio::spawn(async move {
320                let result = run_single_agent(
321                    &group,
322                    &worktree_path,
323                    &changed_files,
324                    correlation_id,
325                    timeout,
326                    &cli_tool,
327                )
328                .await;
329                let _ = tx.send(result).await;
330            });
331            spawned_count += 1;
332        }
333
334        // Collect results with deadline-based timeout
335        drop(tx);
336        let mut agent_outputs = Vec::new();
337        let mut failed_count = 0;
338        let collect_deadline =
339            tokio::time::Instant::now() + self.config.timeout + Duration::from_secs(10);
340
341        loop {
342            match tokio::time::timeout_at(collect_deadline, rx.recv()).await {
343                Ok(Some(result)) => match result {
344                    AgentResult::Success(output) => {
345                        info!(agent = %output.agent, findings = output.findings.len(), "agent completed");
346                        agent_outputs.push(output);
347                    }
348                    AgentResult::Failed { agent_name, reason } => {
349                        warn!(agent = %agent_name, error = %reason, "agent failed");
350                        failed_count += 1;
351                        agent_outputs.push(ReviewAgentOutput {
352                            agent: agent_name,
353                            findings: vec![],
354                            summary: format!("Agent failed: {}", reason),
355                            pass: false,
356                        });
357                    }
358                },
359                Ok(None) => break, // channel closed, all senders dropped
360                Err(_) => {
361                    warn!("collection deadline exceeded, using partial results");
362                    break;
363                }
364            }
365        }
366
367        // Cleanup worktree
368        if let Err(e) = self.worktree_manager.remove_worktree(&worktree_name).await {
369            warn!(error = %e, "failed to cleanup worktree");
370        }
371
372        // Collect all findings and deduplicate
373        let all_findings: Vec<ReviewFinding> = agent_outputs
374            .iter()
375            .flat_map(|o| o.findings.clone())
376            .collect();
377        let deduplicated = terraphim_types::deduplicate_findings(all_findings);
378
379        // Determine overall pass/fail
380        let pass = agent_outputs.iter().all(|o| o.pass) && failed_count == 0;
381
382        let duration = start.elapsed();
383        info!(
384            correlation_id = %correlation_id,
385            agents_run = spawned_count,
386            agents_failed = failed_count,
387            total_findings = deduplicated.len(),
388            pass = %pass,
389            duration = ?duration,
390            "compound review completed"
391        );
392
393        Ok(CompoundReviewResult {
394            correlation_id,
395            findings: deduplicated,
396            agent_outputs,
397            pass,
398            duration,
399            agents_run: spawned_count,
400            agents_failed: failed_count,
401        })
402    }
403
404    /// Get the default review groups (6 groups).
405    pub fn default_groups() -> Vec<ReviewGroupDef> {
406        default_groups()
407    }
408
409    /// Check if there are visual changes in the changed files.
410    pub fn has_visual_changes(changed_files: &[String]) -> bool {
411        has_visual_changes(changed_files)
412    }
413
414    /// Extract ReviewAgentOutput from agent stdout.
415    pub fn extract_review_output(
416        stdout: &str,
417        agent_name: &str,
418        category: FindingCategory,
419    ) -> ReviewAgentOutput {
420        extract_review_output(stdout, agent_name, category)
421    }
422
423    /// Get list of changed files between two git refs.
424    async fn get_changed_files(
425        &self,
426        git_ref: &str,
427        base_ref: &str,
428    ) -> Result<Vec<String>, OrchestratorError> {
429        let output = tokio::process::Command::new("git")
430            .args([
431                "-C",
432                self.config.repo_path.to_str().unwrap_or("."),
433                "diff",
434                "--name-only",
435                base_ref,
436                git_ref,
437            ])
438            .env_remove("GIT_INDEX_FILE")
439            .output()
440            .await
441            .map_err(|e| {
442                OrchestratorError::CompoundReviewFailed(format!("git diff failed: {}", e))
443            })?;
444
445        if !output.status.success() {
446            let stderr = String::from_utf8_lossy(&output.stderr);
447            return Err(OrchestratorError::CompoundReviewFailed(format!(
448                "git diff returned non-zero: {}",
449                stderr
450            )));
451        }
452
453        let stdout = String::from_utf8_lossy(&output.stdout);
454        let files: Vec<String> = stdout
455            .lines()
456            .filter(|line| !line.trim().is_empty())
457            .map(|line| line.to_string())
458            .collect();
459
460        Ok(files)
461    }
462
463    /// Check if the compound review is in dry-run mode.
464    pub fn is_dry_run(&self) -> bool {
465        !self.config.create_prs
466    }
467}
468
469/// Result from a single agent execution.
470enum AgentResult {
471    Success(ReviewAgentOutput),
472    Failed { agent_name: String, reason: String },
473}
474
475/// Run a single review agent.
476async fn run_single_agent(
477    group: &ReviewGroupDef,
478    worktree_path: &Path,
479    changed_files: &[String],
480    _correlation_id: Uuid,
481    timeout: Duration,
482    cli_tool: &str,
483) -> AgentResult {
484    let agent_name = &group.agent_name;
485
486    // Use embedded prompt content (no filesystem access needed)
487    let prompt = group.prompt_content;
488
489    // Build the command with CLI-specific argument formatting
490    let mut cmd = tokio::process::Command::new(cli_tool);
491
492    // Determine CLI name for argument format selection
493    let cli_name = std::path::Path::new(cli_tool)
494        .file_name()
495        .and_then(|n| n.to_str())
496        .unwrap_or(cli_tool);
497
498    match cli_name {
499        "opencode" => {
500            cmd.arg("run").arg("--format").arg("json");
501            if let Some(ref model) = group.model {
502                cmd.arg("-m").arg(model);
503            }
504            cmd.arg(prompt);
505        }
506        "claude" | "claude-code" => {
507            cmd.arg("-p").arg(prompt);
508            if let Some(ref model) = group.model {
509                cmd.arg("--model").arg(model);
510            }
511        }
512        "codex" => {
513            cmd.arg("exec").arg("--full-auto");
514            if let Some(ref model) = group.model {
515                cmd.arg("-m").arg(model);
516            }
517            cmd.arg(prompt);
518        }
519        _ => {
520            cmd.arg(prompt);
521        }
522    }
523    cmd.current_dir(worktree_path);
524
525    // Add changed files as arguments
526    for file in changed_files {
527        cmd.arg(file);
528    }
529
530    debug!(
531        agent = %agent_name,
532        command = ?cmd,
533        "spawning review agent"
534    );
535
536    // Run with timeout
537    let result = tokio::time::timeout(timeout, cmd.output()).await;
538
539    match result {
540        Ok(Ok(output)) => {
541            let stdout = String::from_utf8_lossy(&output.stdout);
542            let review_output = extract_review_output(&stdout, agent_name, group.category);
543            AgentResult::Success(review_output)
544        }
545        Ok(Err(e)) => AgentResult::Failed {
546            agent_name: agent_name.clone(),
547            reason: format!("command execution failed: {}", e),
548        },
549        Err(_) => AgentResult::Failed {
550            agent_name: agent_name.clone(),
551            reason: "timeout exceeded".to_string(),
552        },
553    }
554}
555
556/// Extract ReviewAgentOutput from agent stdout.
557/// Scans stdout for JSON matching ReviewAgentOutput schema.
558/// Graceful fallback: empty output with pass: true if no valid JSON found.
559fn extract_review_output(
560    stdout: &str,
561    agent_name: &str,
562    category: FindingCategory,
563) -> ReviewAgentOutput {
564    // Step 1: Unwrap opencode JSON protocol if present.
565    // opencode --format json wraps all output as:
566    //   {"type":"text","part":{"type":"text","text":"..."}}
567    // We extract the inner text content and concatenate it.
568    let unwrapped = unwrap_opencode_protocol(stdout);
569
570    // Step 2: Scan for ReviewAgentOutput JSON
571    for line in unwrapped.lines() {
572        let trimmed = line.trim();
573        if trimmed.is_empty() {
574            continue;
575        }
576
577        // Try to parse as ReviewAgentOutput directly
578        if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(trimmed) {
579            return output;
580        }
581
582        // Try to parse inside markdown code blocks
583        if trimmed.starts_with("```json") {
584            let json_content = trimmed
585                .strip_prefix("```json")
586                .and_then(|s| s.strip_suffix("```"))
587                .or_else(|| {
588                    trimmed
589                        .strip_prefix("```json")
590                        .map(|s| s.trim_end_matches("```"))
591                });
592
593            if let Some(content) = json_content {
594                let clean_content = content.trim();
595                if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(clean_content) {
596                    return output;
597                }
598            }
599        }
600    }
601
602    // Step 3: Fallback — try to parse entire unwrapped output as JSON
603    if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(&unwrapped) {
604        return output;
605    }
606
607    // Step 4: Heuristic — if output contains finding-like keywords, create synthetic findings
608    let mut findings = vec![];
609    let _lower = unwrapped.to_lowercase();
610    for line in unwrapped.lines() {
611        let line_lower = line.to_lowercase();
612        if line_lower.contains("critical")
613            || line_lower.contains("vulnerability")
614            || line_lower.contains("cve-")
615            || line_lower.contains("rustsec-")
616        {
617            let severity = if line_lower.contains("critical") {
618                FindingSeverity::Critical
619            } else if line_lower.contains("high") {
620                FindingSeverity::High
621            } else {
622                FindingSeverity::Medium
623            };
624            findings.push(ReviewFinding {
625                file: String::new(),
626                line: 0,
627                severity,
628                category,
629                finding: line.trim().to_string(),
630                confidence: 0.7,
631                suggestion: None,
632            });
633        }
634    }
635
636    if !findings.is_empty() {
637        let count = findings.len();
638        return ReviewAgentOutput {
639            agent: agent_name.to_string(),
640            findings,
641            summary: format!("Extracted {} findings from unstructured output", count),
642            pass: false,
643        };
644    }
645
646    // No parseable output
647    ReviewAgentOutput {
648        agent: agent_name.to_string(),
649        findings: vec![],
650        summary: format!(
651            "No structured output found in agent response. Output length: {} chars",
652            unwrapped.len()
653        ),
654        pass: false,
655    }
656}
657
658/// Unwrap opencode JSON protocol lines into plain text.
659///
660/// opencode `--format json` outputs lines like:
661///   {"type":"text","part":{"type":"text","text":"actual content here"}}
662///   {"type":"tool_use","part":{"tool":"write",...}}
663///
664/// This function extracts all `text` content from these protocol messages
665/// and returns the concatenated plain text.
666fn unwrap_opencode_protocol(stdout: &str) -> String {
667    use serde_json::Value;
668
669    let mut result = String::new();
670    let mut has_protocol = false;
671
672    for line in stdout.lines() {
673        let trimmed = line.trim();
674        if trimmed.is_empty() {
675            continue;
676        }
677
678        if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
679            // opencode protocol: {"type":"text","part":{"type":"text","text":"..."}}
680            if val.is_object() {
681                if let Some(text) = val
682                    .get("part")
683                    .and_then(|p| p.get("text"))
684                    .and_then(|t| t.as_str())
685                {
686                    has_protocol = true;
687                    result.push_str(text);
688                    result.push('\n');
689                    continue;
690                }
691                // Also check direct "text" field
692                if let Some(text) = val.get("text").and_then(|t| t.as_str()) {
693                    has_protocol = true;
694                    result.push_str(text);
695                    result.push('\n');
696                    continue;
697                }
698                // Format other opencode protocol messages (tool_use, tool_result, etc.)
699                // as brief summaries instead of keeping raw JSON. Raw payloads contain
700                // file content that triggers false positives in the heuristic scanner.
701                if let Some(msg_type) = val.get("type").and_then(|t| t.as_str()) {
702                    has_protocol = true;
703                    let tool_name = val
704                        .get("part")
705                        .and_then(|p| p.get("tool"))
706                        .and_then(|t| t.as_str())
707                        .unwrap_or("unknown");
708                    let status = val
709                        .get("part")
710                        .and_then(|p| p.get("state"))
711                        .and_then(|s| s.get("status"))
712                        .and_then(|s| s.as_str())
713                        .unwrap_or("");
714                    let input_path = val
715                        .get("part")
716                        .and_then(|p| p.get("state"))
717                        .and_then(|s| s.get("input"))
718                        .and_then(|i| {
719                            i.get("filePath")
720                                .or_else(|| i.get("path"))
721                                .or_else(|| i.get("command"))
722                        })
723                        .and_then(|v| v.as_str())
724                        .unwrap_or("");
725                    if input_path.is_empty() {
726                        result.push_str(&format!("[{}: {}]\n", msg_type, tool_name));
727                    } else {
728                        result.push_str(&format!(
729                            "[{}: {} {} {}]\n",
730                            msg_type, tool_name, input_path, status
731                        ));
732                    }
733                    continue;
734                }
735            }
736        }
737
738        // Not protocol JSON — keep as-is
739        result.push_str(trimmed);
740        result.push('\n');
741    }
742
743    if has_protocol {
744        result
745    } else {
746        stdout.to_string()
747    }
748}
749
750/// Check if there are visual/design changes in the changed files.
751fn has_visual_changes(changed_files: &[String]) -> bool {
752    let visual_patterns = get_visual_patterns();
753
754    for file in changed_files {
755        for pattern in &visual_patterns {
756            if glob_matches(file, pattern) {
757                return true;
758            }
759        }
760    }
761
762    false
763}
764
765/// Get visual file detection patterns.
766fn get_visual_patterns() -> Vec<&'static str> {
767    vec![
768        "*.css",
769        "*.scss",
770        "tokens.*",
771        "DESIGN.md",
772        "*.svelte",
773        "*.tsx",
774        "*.vue",
775        "src/components/*",
776        "src/ui/*",
777        "design-system/*",
778    ]
779}
780
781/// Check if a file path matches a glob pattern.
782/// Supports: *.ext, prefix.*, directory/*, exact matches
783fn glob_matches(file: &str, pattern: &str) -> bool {
784    // Exact match
785    if file == pattern {
786        return true;
787    }
788
789    // Extension pattern: *.css
790    if pattern.starts_with("*.") {
791        let ext = &pattern[1..]; // .css
792        if file.ends_with(ext) {
793            return true;
794        }
795    }
796
797    // Prefix pattern with wildcard: tokens.*
798    if pattern.ends_with(".*") {
799        let prefix = &pattern[..pattern.len() - 1]; // tokens.
800        if file.starts_with(prefix) {
801            return true;
802        }
803    }
804
805    // Directory pattern: src/components/*
806    if pattern.ends_with("/*") {
807        let prefix = &pattern[..pattern.len() - 1]; // src/components/
808        if file.starts_with(prefix) {
809            return true;
810        }
811    }
812
813    // Prefix pattern without wildcard
814    if pattern.ends_with('/') && file.starts_with(pattern) {
815        return true;
816    }
817
818    false
819}
820
821/// Get the default 6 review groups.
822fn default_groups() -> Vec<ReviewGroupDef> {
823    vec![
824        ReviewGroupDef {
825            agent_name: "security-sentinel".to_string(),
826            category: FindingCategory::Security,
827            llm_tier: "Quick".to_string(),
828            cli_tool: "opencode".to_string(),
829            model: None,
830            prompt_template: "crates/terraphim_orchestrator/prompts/review-security.md".to_string(),
831            prompt_content: PROMPT_SECURITY,
832            visual_only: false,
833            persona: Some("Vigil".to_string()),
834        },
835        ReviewGroupDef {
836            agent_name: "architecture-strategist".to_string(),
837            category: FindingCategory::Architecture,
838            llm_tier: "Deep".to_string(),
839            cli_tool: "claude".to_string(),
840            model: None,
841            prompt_template: "crates/terraphim_orchestrator/prompts/review-architecture.md"
842                .to_string(),
843            prompt_content: PROMPT_ARCHITECTURE,
844            visual_only: false,
845            persona: Some("Carthos".to_string()),
846        },
847        ReviewGroupDef {
848            agent_name: "performance-oracle".to_string(),
849            category: FindingCategory::Performance,
850            llm_tier: "Deep".to_string(),
851            cli_tool: "claude".to_string(),
852            model: None,
853            prompt_template: "crates/terraphim_orchestrator/prompts/review-performance.md"
854                .to_string(),
855            prompt_content: PROMPT_PERFORMANCE,
856            visual_only: false,
857            persona: Some("Ferrox".to_string()),
858        },
859        ReviewGroupDef {
860            agent_name: "rust-reviewer".to_string(),
861            category: FindingCategory::Quality,
862            llm_tier: "Deep".to_string(),
863            cli_tool: "claude".to_string(),
864            model: None,
865            prompt_template: "crates/terraphim_orchestrator/prompts/review-quality.md".to_string(),
866            prompt_content: PROMPT_QUALITY,
867            visual_only: false,
868            persona: Some("Ferrox".to_string()),
869        },
870        ReviewGroupDef {
871            agent_name: "domain-model-reviewer".to_string(),
872            category: FindingCategory::Domain,
873            llm_tier: "Quick".to_string(),
874            cli_tool: "opencode".to_string(),
875            model: None,
876            prompt_template: "crates/terraphim_orchestrator/prompts/review-domain.md".to_string(),
877            prompt_content: PROMPT_DOMAIN,
878            visual_only: false,
879            persona: Some("Carthos".to_string()),
880        },
881        ReviewGroupDef {
882            agent_name: "design-fidelity-reviewer".to_string(),
883            category: FindingCategory::DesignQuality,
884            llm_tier: "Deep".to_string(),
885            cli_tool: "claude".to_string(),
886            model: None,
887            prompt_template: "crates/terraphim_orchestrator/prompts/review-design-quality.md"
888                .to_string(),
889            prompt_content: PROMPT_DESIGN_QUALITY,
890            visual_only: true,
891            persona: Some("Lux".to_string()),
892        },
893    ]
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899    use terraphim_types::FindingSeverity;
900
901    // ==================== Visual File Detection Tests ====================
902
903    #[test]
904    fn test_visual_file_detection_css() {
905        let files = vec!["styles.css".to_string()];
906        assert!(has_visual_changes(&files));
907    }
908
909    #[test]
910    fn test_visual_file_detection_tsx() {
911        let files = vec!["src/components/Button.tsx".to_string()];
912        assert!(has_visual_changes(&files));
913    }
914
915    #[test]
916    fn test_visual_file_detection_design_md() {
917        let files = vec!["DESIGN.md".to_string()];
918        assert!(has_visual_changes(&files));
919    }
920
921    #[test]
922    fn test_visual_file_detection_rust_only() {
923        let files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
924        assert!(!has_visual_changes(&files));
925    }
926
927    #[test]
928    fn test_visual_file_detection_component_dir() {
929        let files = vec!["src/components/mod.rs".to_string()];
930        assert!(has_visual_changes(&files));
931    }
932
933    #[test]
934    fn test_visual_file_detection_tokens() {
935        let files = vec!["tokens.json".to_string()];
936        assert!(has_visual_changes(&files));
937    }
938
939    // ==================== Extract Review Output Tests ====================
940
941    #[test]
942    fn test_extract_review_output_valid_json() {
943        let json = r#"{"agent":"test-agent","findings":[],"summary":"All good","pass":true}"#;
944        let output = extract_review_output(json, "test-agent", FindingCategory::Quality);
945        assert_eq!(output.agent, "test-agent");
946        assert!(output.pass);
947        assert_eq!(output.findings.len(), 0);
948    }
949
950    #[test]
951    fn test_extract_review_output_mixed_output() {
952        let mixed = r#"Some log output here
953{"agent":"test-agent","findings":[{"file":"src/lib.rs","line":42,"severity":"high","category":"security","finding":"Test issue","confidence":0.9}],"summary":"Found 1 issue","pass":false}
954More logs..."#;
955        let output = extract_review_output(mixed, "test-agent", FindingCategory::Security);
956        assert_eq!(output.agent, "test-agent");
957        assert!(!output.pass);
958        assert_eq!(output.findings.len(), 1);
959        assert_eq!(output.findings[0].severity, FindingSeverity::High);
960    }
961
962    #[test]
963    fn test_extract_review_output_no_json() {
964        let no_json = "Just some plain text output without JSON";
965        let output = extract_review_output(no_json, "test-agent", FindingCategory::Quality);
966        assert_eq!(output.agent, "test-agent");
967        assert!(!output.pass); // Unparseable output treated as failure
968        assert_eq!(output.findings.len(), 0);
969    }
970
971    #[test]
972    fn test_extract_review_output_markdown_code_block() {
973        let markdown = r#"Here's my review:
974
975```json
976{"agent":"test-agent","findings":[],"summary":"No issues","pass":true}
977```
978
979Done!"#;
980        let output = extract_review_output(markdown, "test-agent", FindingCategory::Quality);
981        assert_eq!(output.agent, "test-agent");
982        assert!(output.pass);
983    }
984
985    // ==================== Default Groups Tests ====================
986
987    #[test]
988    fn test_default_groups_count() {
989        let groups = default_groups();
990        assert_eq!(groups.len(), 6);
991    }
992
993    #[test]
994    fn test_default_groups_one_visual_only() {
995        let groups = default_groups();
996        let visual_only_count = groups.iter().filter(|g| g.visual_only).count();
997        assert_eq!(visual_only_count, 1);
998
999        // Verify it's the design-fidelity-reviewer
1000        let visual_group = groups.iter().find(|g| g.visual_only).unwrap();
1001        assert_eq!(visual_group.agent_name, "design-fidelity-reviewer");
1002        assert_eq!(visual_group.category, FindingCategory::DesignQuality);
1003    }
1004
1005    #[test]
1006    fn test_default_groups_categories() {
1007        let groups = default_groups();
1008        let categories: Vec<_> = groups.iter().map(|g| g.category).collect();
1009
1010        assert!(categories.contains(&FindingCategory::Security));
1011        assert!(categories.contains(&FindingCategory::Architecture));
1012        assert!(categories.contains(&FindingCategory::Performance));
1013        assert!(categories.contains(&FindingCategory::Quality));
1014        assert!(categories.contains(&FindingCategory::Domain));
1015        assert!(categories.contains(&FindingCategory::DesignQuality));
1016    }
1017
1018    // ==================== Glob Matching Tests ====================
1019
1020    #[test]
1021    fn test_glob_matches_extension() {
1022        assert!(glob_matches("styles.css", "*.css"));
1023        assert!(glob_matches("app.scss", "*.scss"));
1024        assert!(glob_matches("Component.tsx", "*.tsx"));
1025        assert!(!glob_matches("main.rs", "*.css"));
1026    }
1027
1028    #[test]
1029    fn test_glob_matches_directory() {
1030        assert!(glob_matches("src/components/Button.rs", "src/components/*"));
1031        assert!(glob_matches("src/ui/mod.rs", "src/ui/*"));
1032        assert!(!glob_matches("src/main.rs", "src/components/*"));
1033    }
1034
1035    #[test]
1036    fn test_glob_matches_exact() {
1037        assert!(glob_matches("DESIGN.md", "DESIGN.md"));
1038        assert!(!glob_matches("README.md", "DESIGN.md"));
1039    }
1040
1041    #[test]
1042    fn test_glob_matches_design_system() {
1043        assert!(glob_matches("design-system/tokens.css", "design-system/*"));
1044        assert!(glob_matches(
1045            "design-system/components/button.css",
1046            "design-system/*"
1047        ));
1048    }
1049
1050    // ==================== Compound Review Integration Tests ====================
1051
1052    #[tokio::test]
1053    async fn test_compound_review_dry_run() {
1054        let swarm_config = SwarmConfig {
1055            groups: default_groups(),
1056            timeout: Duration::from_secs(60),
1057            worktree_root: std::env::temp_dir().join("test-compound-review-worktrees"),
1058            repo_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."),
1059            base_branch: "main".to_string(),
1060            max_concurrent_agents: 3,
1061            create_prs: false,
1062        };
1063
1064        let workflow = CompoundReviewWorkflow::new(swarm_config);
1065        assert!(workflow.is_dry_run());
1066    }
1067
1068    #[tokio::test]
1069    async fn test_get_changed_files_real_repo() {
1070        let swarm_config = SwarmConfig {
1071            groups: default_groups(),
1072            timeout: Duration::from_secs(60),
1073            worktree_root: std::env::temp_dir().join("test-compound-review-worktrees"),
1074            repo_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."),
1075            base_branch: "main".to_string(),
1076            max_concurrent_agents: 3,
1077            create_prs: false,
1078        };
1079
1080        let workflow = CompoundReviewWorkflow::new(swarm_config);
1081
1082        // Test with HEAD vs HEAD~1 (should work in any repo with history)
1083        let result = workflow.get_changed_files("HEAD", "HEAD~1").await;
1084
1085        // The result may fail if there's no history, but it should not panic
1086        match result {
1087            Ok(files) => {
1088                // If we have files, they should be valid paths
1089                for file in &files {
1090                    assert!(!file.is_empty());
1091                }
1092            }
1093            Err(_) => {
1094                // Error is acceptable in test environment without proper git setup
1095            }
1096        }
1097    }
1098
1099    #[test]
1100    fn test_swarm_config_from_compound_config() {
1101        let compound_config = CompoundReviewConfig {
1102            schedule: "0 2 * * *".to_string(),
1103            max_duration_secs: 1800,
1104            repo_path: PathBuf::from("/tmp/repo"),
1105            create_prs: false,
1106            worktree_root: PathBuf::from("/tmp/worktrees"),
1107            base_branch: "main".to_string(),
1108            max_concurrent_agents: 3,
1109            cli_tool: None,
1110            provider: None,
1111            model: None,
1112            ..Default::default()
1113        };
1114
1115        let swarm_config = SwarmConfig::from_compound_config(&compound_config);
1116
1117        assert_eq!(swarm_config.repo_path, PathBuf::from("/tmp/repo"));
1118        assert_eq!(swarm_config.worktree_root, PathBuf::from("/tmp/worktrees"));
1119        assert_eq!(swarm_config.base_branch, "main");
1120        assert_eq!(swarm_config.max_concurrent_agents, 3);
1121        assert!(!swarm_config.create_prs);
1122        assert_eq!(swarm_config.groups.len(), 6);
1123    }
1124
1125    #[test]
1126    fn test_compound_review_result_structure() {
1127        let result = CompoundReviewResult {
1128            correlation_id: Uuid::new_v4(),
1129            findings: vec![],
1130            agent_outputs: vec![],
1131            pass: true,
1132            duration: Duration::from_secs(10),
1133            agents_run: 6,
1134            agents_failed: 0,
1135        };
1136
1137        assert!(result.pass);
1138        assert_eq!(result.agents_run, 6);
1139        assert_eq!(result.agents_failed, 0);
1140    }
1141
1142    // ==================== Persona Identity Tests ====================
1143
1144    #[test]
1145    fn test_review_security_contains_vigil() {
1146        let prompt = include_str!("../prompts/review-security.md");
1147        assert!(
1148            prompt.contains("Vigil"),
1149            "review-security.md should contain 'Vigil'"
1150        );
1151        assert!(
1152            prompt.contains("Security Engineer"),
1153            "review-security.md should mention Security Engineer"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_review_architecture_contains_carthos() {
1159        let prompt = include_str!("../prompts/review-architecture.md");
1160        assert!(
1161            prompt.contains("Carthos"),
1162            "review-architecture.md should contain 'Carthos'"
1163        );
1164        assert!(
1165            prompt.contains("Domain Architect"),
1166            "review-architecture.md should mention Domain Architect"
1167        );
1168    }
1169
1170    #[test]
1171    fn test_review_quality_contains_ferrox() {
1172        let prompt = include_str!("../prompts/review-quality.md");
1173        assert!(
1174            prompt.contains("Ferrox"),
1175            "review-quality.md should contain 'Ferrox'"
1176        );
1177        assert!(
1178            prompt.contains("Rust Engineer"),
1179            "review-quality.md should mention Rust Engineer"
1180        );
1181    }
1182
1183    #[test]
1184    fn test_review_performance_contains_ferrox() {
1185        let prompt = include_str!("../prompts/review-performance.md");
1186        assert!(
1187            prompt.contains("Ferrox"),
1188            "review-performance.md should contain 'Ferrox'"
1189        );
1190        assert!(
1191            prompt.contains("Rust Engineer"),
1192            "review-performance.md should mention Rust Engineer"
1193        );
1194    }
1195
1196    #[test]
1197    fn test_review_domain_contains_carthos() {
1198        let prompt = include_str!("../prompts/review-domain.md");
1199        assert!(
1200            prompt.contains("Carthos"),
1201            "review-domain.md should contain 'Carthos'"
1202        );
1203        assert!(
1204            prompt.contains("Domain Architect"),
1205            "review-domain.md should mention Domain Architect"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_review_design_contains_lux() {
1211        let prompt = include_str!("../prompts/review-design-quality.md");
1212        assert!(
1213            prompt.contains("Lux"),
1214            "review-design-quality.md should contain 'Lux'"
1215        );
1216        assert!(
1217            prompt.contains("TypeScript Engineer"),
1218            "review-design-quality.md should mention TypeScript Engineer"
1219        );
1220    }
1221
1222    #[test]
1223    fn test_default_groups_all_have_persona() {
1224        let groups = default_groups();
1225        for group in &groups {
1226            assert!(
1227                group.persona.is_some(),
1228                "Group '{}' should have a persona set",
1229                group.agent_name
1230            );
1231        }
1232
1233        // Verify specific persona mappings
1234        let vigil = groups
1235            .iter()
1236            .find(|g| g.agent_name == "security-sentinel")
1237            .unwrap();
1238        assert_eq!(vigil.persona.as_ref().unwrap(), "Vigil");
1239
1240        let carthos_arch = groups
1241            .iter()
1242            .find(|g| g.agent_name == "architecture-strategist")
1243            .unwrap();
1244        assert_eq!(carthos_arch.persona.as_ref().unwrap(), "Carthos");
1245
1246        let ferrox_perf = groups
1247            .iter()
1248            .find(|g| g.agent_name == "performance-oracle")
1249            .unwrap();
1250        assert_eq!(ferrox_perf.persona.as_ref().unwrap(), "Ferrox");
1251
1252        let ferrox_qual = groups
1253            .iter()
1254            .find(|g| g.agent_name == "rust-reviewer")
1255            .unwrap();
1256        assert_eq!(ferrox_qual.persona.as_ref().unwrap(), "Ferrox");
1257
1258        let carthos_domain = groups
1259            .iter()
1260            .find(|g| g.agent_name == "domain-model-reviewer")
1261            .unwrap();
1262        assert_eq!(carthos_domain.persona.as_ref().unwrap(), "Carthos");
1263
1264        let lux = groups
1265            .iter()
1266            .find(|g| g.agent_name == "design-fidelity-reviewer")
1267            .unwrap();
1268        assert_eq!(lux.persona.as_ref().unwrap(), "Lux");
1269    }
1270
1271    #[test]
1272    fn test_extract_review_output_with_persona_agent_name() {
1273        // Verify JSON output still parses when agent name includes persona
1274        let json = r#"{"agent":"Vigil-security-sentinel","findings":[{"file":"src/lib.rs","line":42,"severity":"high","category":"security","finding":"Test issue","confidence":0.9}],"summary":"Found 1 security issue","pass":false}"#;
1275        let output =
1276            extract_review_output(json, "Vigil-security-sentinel", FindingCategory::Security);
1277        assert_eq!(output.agent, "Vigil-security-sentinel");
1278        assert!(!output.pass);
1279        assert_eq!(output.findings.len(), 1);
1280    }
1281
1282    // =========================================================================
1283    // ADF Remediation Tests (Gitea #117)
1284    // =========================================================================
1285
1286    #[test]
1287    fn test_compound_config_cli_tool_override() {
1288        let config = CompoundReviewConfig {
1289            schedule: "0 2 * * *".to_string(),
1290            max_duration_secs: 1800,
1291            repo_path: PathBuf::from("/tmp"),
1292            create_prs: false,
1293            worktree_root: PathBuf::from("/tmp/worktrees"),
1294            base_branch: "main".to_string(),
1295            max_concurrent_agents: 3,
1296            cli_tool: Some("/home/alex/.bun/bin/opencode".to_string()),
1297            provider: Some("opencode-go".to_string()),
1298            model: Some("glm-5".to_string()),
1299            ..Default::default()
1300        };
1301        let swarm = SwarmConfig::from_compound_config(&config);
1302        for group in &swarm.groups {
1303            assert_eq!(group.cli_tool, "/home/alex/.bun/bin/opencode");
1304            assert_eq!(group.model, Some("opencode-go/glm-5".to_string()));
1305        }
1306    }
1307
1308    #[test]
1309    fn test_compound_config_no_override() {
1310        let config = CompoundReviewConfig {
1311            schedule: "0 2 * * *".to_string(),
1312            max_duration_secs: 1800,
1313            repo_path: PathBuf::from("/tmp"),
1314            create_prs: false,
1315            worktree_root: PathBuf::from("/tmp/worktrees"),
1316            base_branch: "main".to_string(),
1317            max_concurrent_agents: 3,
1318            cli_tool: None,
1319            provider: None,
1320            model: None,
1321            ..Default::default()
1322        };
1323        let swarm = SwarmConfig::from_compound_config(&config);
1324        // Should use default groups unchanged
1325        assert_eq!(swarm.groups[0].cli_tool, "opencode");
1326        assert!(swarm.groups[0].model.is_none());
1327    }
1328
1329    // ==================== Opencode Protocol Unwrap Tests ====================
1330
1331    #[test]
1332    fn test_unwrap_opencode_protocol_formats_tool_use() {
1333        // Reproduce issue #303: tool_use messages with file content containing
1334        // "critical" were kept as raw JSON, causing the heuristic scanner to
1335        // create bogus CRITICAL findings from protocol payloads.
1336        // Now tool_use is formatted as a brief summary instead.
1337        let protocol_output = r#"{"type":"text","part":{"type":"text","text":"Starting review..."}}
1338{"type":"tool_use","timestamp":1775340045267,"sessionID":"ses_abc","part":{"id":"prt_123","tool":"read","state":{"status":"completed","input":{"filePath":"/tmp/test.rs"},"output":"fn critical_path() { }"}}}
1339{"type":"text","part":{"type":"text","text":"Review complete. No issues found."}}"#;
1340
1341        let unwrapped = unwrap_opencode_protocol(protocol_output);
1342        // Raw payload content must not leak through
1343        assert!(
1344            !unwrapped.contains("critical_path"),
1345            "tool_use payload content should not leak through"
1346        );
1347        // Tool call should be formatted as a brief summary
1348        assert!(
1349            unwrapped.contains("[tool_use: read /tmp/test.rs completed]"),
1350            "tool_use should be formatted as summary, got: {}",
1351            unwrapped
1352        );
1353        assert!(unwrapped.contains("Starting review..."));
1354        assert!(unwrapped.contains("Review complete."));
1355    }
1356
1357    #[test]
1358    fn test_extract_review_output_no_false_critical_from_tool_use() {
1359        // End-to-end: opencode output with tool_use containing "critical"
1360        // should NOT produce synthetic CRITICAL findings.
1361        let protocol_output = r#"{"type":"text","part":{"type":"text","text":"Reviewing code..."}}
1362{"type":"tool_use","part":{"tool":"read","state":{"output":"FindingSeverity::Critical is used here"}}}
1363{"type":"text","part":{"type":"text","text":"All looks good, no issues."}}"#;
1364
1365        let output =
1366            extract_review_output(protocol_output, "test-agent", FindingCategory::Security);
1367        // Should NOT have any findings -- the "Critical" was inside a tool_use payload
1368        assert_eq!(
1369            output.findings.len(),
1370            0,
1371            "tool_use payloads must not generate synthetic findings"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_compound_config_timeout_uses_max_duration() {
1377        let config = CompoundReviewConfig {
1378            schedule: "0 2 * * *".to_string(),
1379            max_duration_secs: 900,
1380            repo_path: PathBuf::from("/tmp"),
1381            create_prs: false,
1382            worktree_root: PathBuf::from("/tmp/worktrees"),
1383            base_branch: "main".to_string(),
1384            max_concurrent_agents: 3,
1385            cli_tool: None,
1386            provider: None,
1387            model: None,
1388            ..Default::default()
1389        };
1390        let swarm = SwarmConfig::from_compound_config(&config);
1391        assert_eq!(swarm.timeout, Duration::from_secs(900));
1392    }
1393}