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 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}