codetether_agent/session/helper/
validation.rs1use 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}