Skip to main content

ralph_workflow/phases/review/
validation.rs

1//! Review phase validation checks.
2//!
3//! This module contains pre-flight and post-flight validation logic for the review phase.
4//! These checks verify that the environment is suitable for running the review agent
5//! and help diagnose issues early.
6
7use crate::agents::{contains_glm_model, is_glm_like_agent};
8use crate::review_metrics::ReviewMetrics;
9use crate::workspace::Workspace;
10use std::path::Path;
11
12/// Result of pre-flight validation
13#[derive(Debug)]
14pub enum PreflightResult {
15    /// All checks passed
16    Ok,
17    /// Warning issued but can proceed
18    Warning(String),
19    /// Critical error that should halt execution
20    Error(String),
21}
22
23/// Result of post-flight validation
24#[derive(Debug)]
25pub enum PostflightResult {
26    /// ISSUES.md found and valid
27    Valid,
28    /// ISSUES.md missing or empty
29    Missing(String),
30    /// ISSUES.md has unexpected format
31    Malformed(String),
32}
33
34/// Run pre-flight validation checks before starting a review pass.
35///
36/// These checks verify that the environment is suitable for running
37/// the review agent and help diagnose issues early.
38///
39/// Uses workspace abstraction for file operations, enabling testing with
40/// `MemoryWorkspace`.
41pub fn pre_flight_review_check(
42    workspace: &dyn Workspace,
43    logger: &crate::logger::Logger,
44    cycle: u32,
45    reviewer_agent: &str,
46    reviewer_model: Option<&str>,
47) -> PreflightResult {
48    let agent_dir = Path::new(".agent");
49    let issues_path = Path::new(".agent/ISSUES.md");
50
51    // Check 0: Agent compatibility warning (non-blocking)
52    let is_problematic_reviewer = is_problematic_prompt_target(reviewer_agent, reviewer_model);
53
54    if is_problematic_reviewer {
55        logger.warn(&format!(
56            "Note: Reviewer may have compatibility issues with review tasks. (agent='{}', model={})",
57            reviewer_agent,
58            reviewer_model.unwrap_or("none")
59        ));
60        logger.info("If review fails, consider these workarounds:");
61        logger.info("  1. Use Claude/Codex as reviewer: ralph --reviewer-agent codex");
62        logger.info("  2. Try generic parser: ralph --reviewer-json-parser generic");
63        logger.info("  3. Skip review: RALPH_REVIEWER_REVIEWS=0 ralph");
64        // Continue anyway - don't block execution
65    }
66
67    // Check 0.1: GLM-specific command validation (diagnostic only)
68    if is_glm_like_agent(reviewer_agent) {
69        // Log diagnostic info about GLM agent configuration
70        logger.info(&format!(
71            "GLM agent detected: '{reviewer_agent}'. Command will include '-p' flag for non-interactive mode."
72        ));
73        logger.info("Tip: Use --verbosity debug to see the full command being executed");
74    }
75
76    // Check 0.5: Check for existing ISSUES.md from previous failed run
77    if workspace.exists(issues_path) {
78        // Try to read to check if it has content
79        match workspace.read(issues_path) {
80            Ok(content) if !content.is_empty() => {
81                logger.warn(&format!(
82                    "ISSUES.md already exists from a previous run (size: {} bytes).",
83                    content.len()
84                ));
85                logger
86                    .info("The review agent will overwrite this file. If the previous run failed,");
87                logger.info("consider checking the old ISSUES.md for clues about what went wrong.");
88            }
89            Ok(_) => {
90                // Empty ISSUES.md - warn but continue
91                logger.warn("Found empty ISSUES.md from previous run. Will be overwritten.");
92            }
93            Err(e) => {
94                logger.warn(&format!("Cannot read ISSUES.md: {e}"));
95            }
96        }
97    }
98
99    // Check 1: Verify .agent directory is writable
100    if !workspace.is_dir(agent_dir) {
101        // Try to create it
102        if let Err(e) = workspace.create_dir_all(agent_dir) {
103            return PreflightResult::Error(format!(
104                "Cannot create .agent directory: {e}. Check directory permissions."
105            ));
106        }
107    }
108
109    // Test write by touching a temp file
110    let test_file = agent_dir.join(format!(".write_test_{cycle}"));
111    match workspace.write(&test_file, "test") {
112        Ok(()) => {
113            let _ = workspace.remove(&test_file);
114        }
115        Err(e) => {
116            return PreflightResult::Error(format!(
117                ".agent directory is not writable: {e}. Check file permissions."
118            ));
119        }
120    }
121
122    // Check 2: Check number of files in .agent directory
123    // (workspace read_dir gives us entry count without needing metadata)
124    if let Ok(entries) = workspace.read_dir(agent_dir) {
125        let entry_count = entries.len();
126        if entry_count > 1000 {
127            logger.warn(&format!(
128                ".agent directory has {entry_count} files. Consider cleaning up old logs."
129            ));
130            return PreflightResult::Warning(
131                "Large .agent directory detected. Review may be slow.".to_string(),
132            );
133        }
134    }
135
136    PreflightResult::Ok
137}
138
139/// Run post-flight validation after a review pass completes.
140///
141/// These checks verify that the review agent produced expected output.
142///
143/// Uses workspace abstraction for file operations, enabling testing with
144/// `MemoryWorkspace`.
145pub fn post_flight_review_check(
146    workspace: &dyn Workspace,
147    logger: &crate::logger::Logger,
148    cycle: u32,
149) -> PostflightResult {
150    let issues_path = Path::new(".agent/ISSUES.md");
151
152    // Check 1: Verify ISSUES.md exists
153    if !workspace.exists(issues_path) {
154        logger.warn(&format!(
155            "Review cycle {cycle} completed but ISSUES.md was not created. \
156             The agent may have failed or used a different output format."
157        ));
158        logger.info("Possible causes:");
159        logger.info("  - Agent failed to write the file (permission/execution error)");
160        logger.info("  - Agent used a different output filename or format");
161        logger.info("  - Agent was interrupted during execution");
162        return PostflightResult::Missing(
163            "ISSUES.md not found after review. Agent may have failed.".to_string(),
164        );
165    }
166
167    // Check 2: Verify ISSUES.md is not empty and log its size
168    let file_size = match workspace.read(issues_path) {
169        Ok(content) if content.is_empty() => {
170            logger.warn(&format!("Review cycle {cycle} created an empty ISSUES.md."));
171            logger.info("Possible causes:");
172            logger.info("  - Agent reviewed but found no issues (should write 'No issues found.')");
173            logger.info("  - Agent failed during file write");
174            logger.info("  - Agent doesn't understand the expected output format");
175            return PostflightResult::Missing("ISSUES.md is empty".to_string());
176        }
177        Ok(content) => {
178            // Log the file size for debugging
179            let size = content.len() as u64;
180            logger.info(&format!("ISSUES.md created ({} bytes)", size));
181            size
182        }
183        Err(e) => {
184            logger.warn(&format!("Cannot read ISSUES.md: {e}"));
185            return PostflightResult::Missing(format!("Cannot read ISSUES.md: {e}"));
186        }
187    };
188
189    // Check 3: Verify ISSUES.md has valid structure
190    match ReviewMetrics::from_issues_file_with_workspace(workspace) {
191        Ok(metrics) => {
192            // Check if metrics indicate reasonable content
193            if metrics.total_issues == 0 && !metrics.no_issues_declared {
194                // Partial recovery: file has content but no parseable issues
195                logger.warn(&format!(
196                    "Review cycle {cycle} produced ISSUES.md ({file_size} bytes) but no parseable issues detected."
197                ));
198                logger.info("Content may be in unexpected format. The fix pass may still work.");
199                logger.info(
200                    "Consider checking .agent/ISSUES.md manually to see what the agent wrote.",
201                );
202                return PostflightResult::Malformed(
203                    "ISSUES.md exists but no issues detected. Check format.".to_string(),
204                );
205            }
206
207            // Log a summary of what was found
208            if metrics.total_issues > 0 {
209                logger.info(&format!(
210                    "Review found {} issues ({} critical, {} high, {} medium, {} low)",
211                    metrics.total_issues,
212                    metrics.critical_issues,
213                    metrics.high_issues,
214                    metrics.medium_issues,
215                    metrics.low_issues
216                ));
217            } else if metrics.no_issues_declared {
218                logger.info("Review declared no issues found.");
219            }
220
221            PostflightResult::Valid
222        }
223        Err(e) => {
224            // Partial recovery: attempt to show what content we can
225            logger.warn(&format!("Failed to parse ISSUES.md: {e}"));
226            logger.info(&format!(
227                "ISSUES.md has {file_size} bytes but failed to parse."
228            ));
229            logger.info("The file may be malformed or in an unexpected format.");
230            logger.info(
231                "Attempting partial recovery: fix pass will proceed but may have limited success.",
232            );
233
234            // Try to read first few lines to give user a hint
235            if let Ok(content) = workspace.read(issues_path) {
236                let preview: String = content.lines().take(5).collect::<Vec<_>>().join("\n");
237                if !preview.is_empty() {
238                    logger.info("ISSUES.md preview (first 5 lines):");
239                    for line in preview.lines() {
240                        logger.info(&format!("  {line}"));
241                    }
242                }
243            }
244
245            PostflightResult::Malformed(format!("Failed to parse ISSUES.md: {e}"))
246        }
247    }
248}
249
250/// Check if the given agent/model combination is a problematic prompt target.
251///
252/// Certain AI agents have known compatibility issues with complex structured prompts.
253/// This function detects those agents for which alternative handling may be needed.
254fn is_problematic_prompt_target(agent: &str, model_flag: Option<&str>) -> bool {
255    contains_glm_model(agent) || model_flag.is_some_and(contains_glm_model)
256}