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    if let Some(report) = super::refactor_guard::evaluate(workspace_dir, &existing_files).await? {
118        return Ok(Some(report));
119    }
120
121    let root_uri = format!("file://{}", workspace_dir.display());
122    let lsp_tool = LspTool::with_root(root_uri);
123    let manager = lsp_tool.get_manager().await;
124    let mut issues_by_file = BTreeMap::new();
125    let mut issue_count = 0usize;
126
127    for path in existing_files {
128        let mut rendered = Vec::new();
129        let client = manager.get_client_for_file(&path).await.ok();
130        if let Some(client) = client
131            && let Ok(LspActionResult::Diagnostics { diagnostics }) =
132                client.diagnostics(&path).await
133        {
134            rendered.extend(render_diagnostics(workspace_dir, &diagnostics));
135        }
136
137        let linter_diagnostics = manager.linter_diagnostics(&path).await;
138        rendered.extend(render_diagnostics(workspace_dir, &linter_diagnostics));
139        rendered.extend(collect_external_linter_diagnostics(workspace_dir, &path).await);
140
141        rendered.sort();
142        rendered.dedup();
143
144        if !rendered.is_empty() {
145            issue_count += rendered.len();
146            issues_by_file.insert(relative_display_path(workspace_dir, &path), rendered);
147        }
148    }
149
150    if issues_by_file.is_empty() {
151        return Ok(None);
152    }
153
154    let mut prompt = String::from(
155        "Mandatory post-edit verification found unresolved diagnostics in files you changed. \
156Do not finish yet. Fix every issue below, respecting workspace config files such as eslint, biome, \
157ruff, stylelint, tsconfig, and project-local language-server settings. Prefer direct file-edit \
158tools on the listed files. Do not wander into unrelated files or exploratory bash loops unless a \
159minimal validation command is strictly necessary. After fixing them, re-check the same files and \
160only then provide the final answer.\n\n",
161    );
162
163    for (path, diagnostics) in issues_by_file {
164        prompt.push_str(&format!("{path}\n"));
165        for diagnostic in diagnostics.iter().take(MAX_DIAGNOSTICS_PER_FILE) {
166            prompt.push_str("  - ");
167            prompt.push_str(diagnostic);
168            prompt.push('\n');
169        }
170        if diagnostics.len() > MAX_DIAGNOSTICS_PER_FILE {
171            prompt.push_str(&format!(
172                "  - ... {} more diagnostics omitted\n",
173                diagnostics.len() - MAX_DIAGNOSTICS_PER_FILE
174            ));
175        }
176        prompt.push('\n');
177    }
178
179    Ok(Some(ValidationReport {
180        issue_count,
181        prompt: prompt.trim_end().to_string(),
182    }))
183}
184
185fn render_diagnostics(
186    workspace_dir: &Path,
187    diagnostics: &[crate::lsp::DiagnosticInfo],
188) -> Vec<String> {
189    diagnostics
190        .iter()
191        .map(|diagnostic| {
192            let path = diagnostic
193                .uri
194                .strip_prefix("file://")
195                .map(PathBuf::from)
196                .unwrap_or_else(|| PathBuf::from(&diagnostic.uri));
197            let path = relative_display_path(workspace_dir, &path);
198            let severity = diagnostic.severity.as_deref().unwrap_or("unknown");
199            let source = diagnostic.source.as_deref().unwrap_or("lsp");
200            let code = diagnostic
201                .code
202                .as_ref()
203                .map(|code| format!(" ({code})"))
204                .unwrap_or_default();
205            format!(
206                "[{severity}] {path}:{}:{} [{source}]{} {}",
207                diagnostic.range.start.line + 1,
208                diagnostic.range.start.character + 1,
209                code,
210                diagnostic.message.replace('\n', " ")
211            )
212        })
213        .collect()
214}
215
216async fn collect_external_linter_diagnostics(workspace_dir: &Path, path: &Path) -> Vec<String> {
217    let ext = path
218        .extension()
219        .and_then(|ext| ext.to_str())
220        .unwrap_or_default();
221    if !matches!(ext, "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs") {
222        return Vec::new();
223    }
224
225    let relative_path = path
226        .strip_prefix(workspace_dir)
227        .unwrap_or(path)
228        .display()
229        .to_string();
230    let output = Command::new("npx")
231        .args(["--no-install", "eslint", "--format", "json", &relative_path])
232        .current_dir(workspace_dir)
233        .output()
234        .await;
235    let Ok(output) = output else {
236        return Vec::new();
237    };
238    if output.stdout.is_empty() {
239        return Vec::new();
240    }
241
242    let reports: Result<Vec<EslintFileReport>, _> = serde_json::from_slice(&output.stdout);
243    let Ok(reports) = reports else {
244        return Vec::new();
245    };
246
247    reports
248        .into_iter()
249        .flat_map(|report| {
250            let file_path = report.file_path;
251            report.messages.into_iter().map(move |message| {
252                let severity = match message.severity {
253                    2 => "error",
254                    1 => "warning",
255                    _ => "info",
256                };
257                let code = message
258                    .rule_id
259                    .as_deref()
260                    .map(|rule_id| format!(" ({rule_id})"))
261                    .unwrap_or_default();
262                format!(
263                    "[{severity}] {}:{}:{} [eslint-cli]{} {}",
264                    relative_display_path(workspace_dir, Path::new(&file_path)),
265                    message.line,
266                    message.column,
267                    code,
268                    message.message.replace('\n', " ")
269                )
270            })
271        })
272        .collect()
273}
274
275fn is_mutating_tool(tool_name: &str) -> bool {
276    matches!(
277        tool_name,
278        "write"
279            | "edit"
280            | "advanced_edit"
281            | "confirm_edit"
282            | "multiedit"
283            | "confirm_multiedit"
284            | "patch"
285    )
286}
287
288fn collect_metadata_paths(
289    files: &mut Vec<String>,
290    tool_metadata: Option<&HashMap<String, Value>>,
291    keys: &[&str],
292) {
293    let Some(tool_metadata) = tool_metadata else {
294        return;
295    };
296
297    for key in keys {
298        let Some(value) = tool_metadata.get(*key) else {
299            continue;
300        };
301
302        match value {
303            Value::String(path) => files.push(path.clone()),
304            Value::Array(paths) => {
305                for path in paths.iter().filter_map(Value::as_str) {
306                    files.push(path.to_string());
307                }
308            }
309            _ => {}
310        }
311    }
312}
313
314fn string_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
315    keys.iter()
316        .find_map(|key| value.get(*key).and_then(Value::as_str))
317}
318
319fn normalize_workspace_path(workspace_dir: &Path, raw_path: &str) -> PathBuf {
320    let path = PathBuf::from(raw_path);
321    if path.is_absolute() {
322        path
323    } else {
324        workspace_dir.join(path)
325    }
326}
327
328fn relative_display_path(workspace_dir: &Path, path: &Path) -> String {
329    path.strip_prefix(workspace_dir)
330        .unwrap_or(path)
331        .display()
332        .to_string()
333}
334
335#[derive(Debug, Deserialize)]
336struct EslintFileReport {
337    #[serde(rename = "filePath")]
338    file_path: String,
339    messages: Vec<EslintMessage>,
340}
341
342#[derive(Debug, Deserialize)]
343struct EslintMessage {
344    #[serde(rename = "ruleId")]
345    rule_id: Option<String>,
346    severity: u8,
347    message: String,
348    line: u32,
349    column: u32,
350}