Skip to main content

codetether_agent/session/helper/
validation.rs

1use crate::lsp::LspActionResult;
2use crate::tool::lsp::LspTool;
3use anyhow::Result;
4use serde::Deserialize;
5use serde_json::Value;
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::path::{Path, PathBuf};
8use tokio::process::Command;
9
10const MAX_DIAGNOSTICS_PER_FILE: usize = 25;
11
12#[derive(Debug, Clone)]
13pub struct ValidationReport {
14    pub issue_count: usize,
15    pub prompt: String,
16}
17
18pub async fn capture_git_dirty_files(workspace_dir: &Path) -> HashSet<PathBuf> {
19    let mut files = HashSet::new();
20    for args in [
21        &["diff", "--name-only", "--relative", "--"][..],
22        &["diff", "--cached", "--name-only", "--relative", "--"][..],
23        &["ls-files", "--others", "--exclude-standard"][..],
24    ] {
25        let output = Command::new("git")
26            .args(args)
27            .current_dir(workspace_dir)
28            .output()
29            .await;
30        let Ok(output) = output else {
31            continue;
32        };
33        if !output.status.success() {
34            continue;
35        }
36        let stdout = String::from_utf8_lossy(&output.stdout);
37        for line in stdout
38            .lines()
39            .map(str::trim)
40            .filter(|line| !line.is_empty())
41        {
42            files.insert(normalize_workspace_path(workspace_dir, line));
43        }
44    }
45    files
46}
47
48pub fn track_touched_files(
49    touched_files: &mut HashSet<PathBuf>,
50    workspace_dir: &Path,
51    tool_name: &str,
52    tool_input: &Value,
53    tool_metadata: Option<&HashMap<String, Value>>,
54) {
55    if !is_mutating_tool(tool_name) {
56        return;
57    }
58
59    let mut files = Vec::new();
60
61    match tool_name {
62        "write" | "edit" | "confirm_edit" => {
63            if let Some(path) = string_field(tool_input, &["path"]) {
64                files.push(path.to_string());
65            }
66        }
67        "advanced_edit" => {
68            if let Some(path) = string_field(tool_input, &["filePath", "file_path", "path"]) {
69                files.push(path.to_string());
70            }
71        }
72        "multiedit" | "confirm_multiedit" => {
73            if let Some(edits) = tool_input.get("edits").and_then(Value::as_array) {
74                for edit in edits {
75                    if let Some(path) = string_field(edit, &["path", "filePath", "file_path"]) {
76                        files.push(path.to_string());
77                    }
78                }
79            }
80        }
81        "patch" => {
82            collect_metadata_paths(&mut files, tool_metadata, &["files"]);
83        }
84        _ => {}
85    }
86
87    collect_metadata_paths(&mut files, tool_metadata, &["path", "file"]);
88
89    for file in files {
90        touched_files.insert(normalize_workspace_path(workspace_dir, &file));
91    }
92}
93
94pub async fn build_validation_report(
95    workspace_dir: &Path,
96    touched_files: &HashSet<PathBuf>,
97    baseline_git_dirty_files: &HashSet<PathBuf>,
98) -> Result<Option<ValidationReport>> {
99    let mut candidate_files = touched_files.clone();
100    let current_git_dirty = capture_git_dirty_files(workspace_dir).await;
101    candidate_files.extend(
102        current_git_dirty
103            .difference(baseline_git_dirty_files)
104            .cloned(),
105    );
106
107    let mut existing_files: Vec<PathBuf> = candidate_files
108        .into_iter()
109        .filter(|path| path.is_file())
110        .collect();
111    existing_files.sort();
112    existing_files.dedup();
113
114    if existing_files.is_empty() {
115        return Ok(None);
116    }
117
118    let root_uri = format!("file://{}", workspace_dir.display());
119    let lsp_tool = LspTool::with_root(root_uri);
120    let manager = lsp_tool.get_manager().await;
121    let mut issues_by_file = BTreeMap::new();
122    let mut issue_count = 0usize;
123
124    for path in existing_files {
125        let mut rendered = Vec::new();
126        let client = manager.get_client_for_file(&path).await.ok();
127        if let Some(client) = client
128            && let Ok(LspActionResult::Diagnostics { diagnostics }) =
129                client.diagnostics(&path).await
130        {
131            rendered.extend(render_diagnostics(workspace_dir, &diagnostics));
132        }
133
134        let linter_diagnostics = manager.linter_diagnostics(&path).await;
135        rendered.extend(render_diagnostics(workspace_dir, &linter_diagnostics));
136        rendered.extend(collect_external_linter_diagnostics(workspace_dir, &path).await);
137
138        rendered.sort();
139        rendered.dedup();
140
141        if !rendered.is_empty() {
142            issue_count += rendered.len();
143            issues_by_file.insert(relative_display_path(workspace_dir, &path), rendered);
144        }
145    }
146
147    if issues_by_file.is_empty() {
148        return Ok(None);
149    }
150
151    let mut prompt = String::from(
152        "Mandatory post-edit verification found unresolved diagnostics in files you changed. \
153Do not finish yet. Fix every issue below, respecting workspace config files such as eslint, biome, \
154ruff, stylelint, tsconfig, and project-local language-server settings. Prefer direct file-edit \
155tools on the listed files. Do not wander into unrelated files or exploratory bash loops unless a \
156minimal validation command is strictly necessary. After fixing them, re-check the same files and \
157only then provide the final answer.\n\n",
158    );
159
160    for (path, diagnostics) in issues_by_file {
161        prompt.push_str(&format!("{path}\n"));
162        for diagnostic in diagnostics.iter().take(MAX_DIAGNOSTICS_PER_FILE) {
163            prompt.push_str("  - ");
164            prompt.push_str(diagnostic);
165            prompt.push('\n');
166        }
167        if diagnostics.len() > MAX_DIAGNOSTICS_PER_FILE {
168            prompt.push_str(&format!(
169                "  - ... {} more diagnostics omitted\n",
170                diagnostics.len() - MAX_DIAGNOSTICS_PER_FILE
171            ));
172        }
173        prompt.push('\n');
174    }
175
176    Ok(Some(ValidationReport {
177        issue_count,
178        prompt: prompt.trim_end().to_string(),
179    }))
180}
181
182fn render_diagnostics(
183    workspace_dir: &Path,
184    diagnostics: &[crate::lsp::DiagnosticInfo],
185) -> Vec<String> {
186    diagnostics
187        .iter()
188        .map(|diagnostic| {
189            let path = diagnostic
190                .uri
191                .strip_prefix("file://")
192                .map(PathBuf::from)
193                .unwrap_or_else(|| PathBuf::from(&diagnostic.uri));
194            let path = relative_display_path(workspace_dir, &path);
195            let severity = diagnostic.severity.as_deref().unwrap_or("unknown");
196            let source = diagnostic.source.as_deref().unwrap_or("lsp");
197            let code = diagnostic
198                .code
199                .as_ref()
200                .map(|code| format!(" ({code})"))
201                .unwrap_or_default();
202            format!(
203                "[{severity}] {path}:{}:{} [{source}]{} {}",
204                diagnostic.range.start.line + 1,
205                diagnostic.range.start.character + 1,
206                code,
207                diagnostic.message.replace('\n', " ")
208            )
209        })
210        .collect()
211}
212
213async fn collect_external_linter_diagnostics(workspace_dir: &Path, path: &Path) -> Vec<String> {
214    let ext = path
215        .extension()
216        .and_then(|ext| ext.to_str())
217        .unwrap_or_default();
218    if !matches!(ext, "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs") {
219        return Vec::new();
220    }
221
222    let relative_path = path
223        .strip_prefix(workspace_dir)
224        .unwrap_or(path)
225        .display()
226        .to_string();
227    let output = Command::new("npx")
228        .args(["--no-install", "eslint", "--format", "json", &relative_path])
229        .current_dir(workspace_dir)
230        .output()
231        .await;
232    let Ok(output) = output else {
233        return Vec::new();
234    };
235    if output.stdout.is_empty() {
236        return Vec::new();
237    }
238
239    let reports: Result<Vec<EslintFileReport>, _> = serde_json::from_slice(&output.stdout);
240    let Ok(reports) = reports else {
241        return Vec::new();
242    };
243
244    reports
245        .into_iter()
246        .flat_map(|report| {
247            let file_path = report.file_path;
248            report.messages.into_iter().map(move |message| {
249                let severity = match message.severity {
250                    2 => "error",
251                    1 => "warning",
252                    _ => "info",
253                };
254                let code = message
255                    .rule_id
256                    .as_deref()
257                    .map(|rule_id| format!(" ({rule_id})"))
258                    .unwrap_or_default();
259                format!(
260                    "[{severity}] {}:{}:{} [eslint-cli]{} {}",
261                    relative_display_path(workspace_dir, Path::new(&file_path)),
262                    message.line,
263                    message.column,
264                    code,
265                    message.message.replace('\n', " ")
266                )
267            })
268        })
269        .collect()
270}
271
272fn is_mutating_tool(tool_name: &str) -> bool {
273    matches!(
274        tool_name,
275        "write"
276            | "edit"
277            | "advanced_edit"
278            | "confirm_edit"
279            | "multiedit"
280            | "confirm_multiedit"
281            | "patch"
282    )
283}
284
285fn collect_metadata_paths(
286    files: &mut Vec<String>,
287    tool_metadata: Option<&HashMap<String, Value>>,
288    keys: &[&str],
289) {
290    let Some(tool_metadata) = tool_metadata else {
291        return;
292    };
293
294    for key in keys {
295        let Some(value) = tool_metadata.get(*key) else {
296            continue;
297        };
298
299        match value {
300            Value::String(path) => files.push(path.clone()),
301            Value::Array(paths) => {
302                for path in paths.iter().filter_map(Value::as_str) {
303                    files.push(path.to_string());
304                }
305            }
306            _ => {}
307        }
308    }
309}
310
311fn string_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
312    keys.iter()
313        .find_map(|key| value.get(*key).and_then(Value::as_str))
314}
315
316fn normalize_workspace_path(workspace_dir: &Path, raw_path: &str) -> PathBuf {
317    let path = PathBuf::from(raw_path);
318    if path.is_absolute() {
319        path
320    } else {
321        workspace_dir.join(path)
322    }
323}
324
325fn relative_display_path(workspace_dir: &Path, path: &Path) -> String {
326    path.strip_prefix(workspace_dir)
327        .unwrap_or(path)
328        .display()
329        .to_string()
330}
331
332#[derive(Debug, Deserialize)]
333struct EslintFileReport {
334    #[serde(rename = "filePath")]
335    file_path: String,
336    messages: Vec<EslintMessage>,
337}
338
339#[derive(Debug, Deserialize)]
340struct EslintMessage {
341    #[serde(rename = "ruleId")]
342    rule_id: Option<String>,
343    severity: u8,
344    message: String,
345    line: u32,
346    column: u32,
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    #[test]
355    fn tracks_edit_paths_from_arguments() {
356        let workspace_dir = Path::new("/workspace");
357        let mut touched_files = HashSet::new();
358        track_touched_files(
359            &mut touched_files,
360            workspace_dir,
361            "edit",
362            &json!({ "path": "src/main.ts" }),
363            None,
364        );
365
366        assert!(touched_files.contains(&PathBuf::from("/workspace/src/main.ts")));
367    }
368
369    #[test]
370    fn tracks_patch_paths_from_metadata() {
371        let workspace_dir = Path::new("/workspace");
372        let mut touched_files = HashSet::new();
373        let metadata =
374            HashMap::from([("files".to_string(), json!(["src/lib.rs", "tests/app.rs"]))]);
375
376        track_touched_files(
377            &mut touched_files,
378            workspace_dir,
379            "patch",
380            &json!({}),
381            Some(&metadata),
382        );
383
384        assert!(touched_files.contains(&PathBuf::from("/workspace/src/lib.rs")));
385        assert!(touched_files.contains(&PathBuf::from("/workspace/tests/app.rs")));
386    }
387}