Skip to main content

git_iris/agents/tools/
static_analysis.rs

1//! Static analysis tool for agent review context.
2
3use anyhow::Result;
4use rig::completion::ToolDefinition;
5use rig::tool::Tool;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::path::Path;
10use std::process::Stdio;
11use std::time::Duration;
12use tokio::process::Command;
13use tokio::time::timeout;
14
15use super::common::{current_repo_root, parameters_schema};
16
17crate::define_tool_error!(StaticAnalysisError);
18
19const DEFAULT_TIMEOUT_SECS: u64 = 300;
20const DEFAULT_MAX_OUTPUT_CHARS: usize = 12_000;
21const MIN_OUTPUT_CHARS: usize = 512;
22const MAX_TIMEOUT_SECS: u64 = 600;
23const MAX_OUTPUT_CHARS: usize = 40_000;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct StaticAnalysis;
27
28#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum StaticAnalyzer {
31    #[default]
32    Auto,
33    Rust,
34    Python,
35    Javascript,
36    Go,
37}
38
39impl fmt::Display for StaticAnalyzer {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::Auto => write!(f, "auto"),
43            Self::Rust => write!(f, "rust"),
44            Self::Python => write!(f, "python"),
45            Self::Javascript => write!(f, "javascript"),
46            Self::Go => write!(f, "go"),
47        }
48    }
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
52pub struct StaticAnalysisArgs {
53    #[serde(default)]
54    pub analyzer: StaticAnalyzer,
55    #[serde(default = "default_timeout_secs")]
56    #[schemars(
57        description = "Seconds to wait per analysis command. Values are clamped to 1..600."
58    )]
59    pub timeout_secs: u64,
60    #[serde(default = "default_max_output_chars")]
61    #[schemars(
62        description = "Maximum characters to return per command. Values are clamped to 512..40000."
63    )]
64    pub max_output_chars: usize,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub(super) struct AnalysisCommand {
69    pub(super) name: &'static str,
70    pub(super) executable: &'static str,
71    pub(super) args: Vec<&'static str>,
72    pub(super) reason: &'static str,
73}
74
75impl Default for StaticAnalysis {
76    fn default() -> Self {
77        Self
78    }
79}
80
81fn default_timeout_secs() -> u64 {
82    DEFAULT_TIMEOUT_SECS
83}
84
85fn default_max_output_chars() -> usize {
86    DEFAULT_MAX_OUTPUT_CHARS
87}
88
89impl Tool for StaticAnalysis {
90    const NAME: &'static str = "static_analysis";
91    type Error = StaticAnalysisError;
92    type Args = StaticAnalysisArgs;
93    type Output = String;
94
95    async fn definition(&self, _: String) -> ToolDefinition {
96        ToolDefinition {
97            name: Self::NAME.to_string(),
98            description: "Run installed static analysis tools directly without performing package install steps. Supports Rust/clippy, Python/ruff, JavaScript or TypeScript/biome or oxlint, and Go/golangci-lint or go vet. Use this during review to prioritize analyzer findings and avoid reporting issues a linter already catches. These tools can execute project build scripts, plugins, or analyzer configuration, so only run them in trusted workspaces. Timeouts clamp to 1..=600 seconds; output truncates to 512..=40000 characters.".to_string(),
99            parameters: parameters_schema::<StaticAnalysisArgs>(),
100        }
101    }
102
103    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
104        let repo_root = current_repo_root()?;
105        let commands = select_analysis_commands(&repo_root, args.analyzer, command_available);
106        if commands.is_empty() {
107            let availability =
108                unavailable_analysis_summary(&repo_root, args.analyzer, command_available);
109            return Ok(format!(
110                "No installed static analysis command found for `{}`. Supported direct commands: cargo, ruff, biome, oxlint, golangci-lint, go.\n{}",
111                args.analyzer,
112                availability.join("\n")
113            ));
114        }
115
116        let timeout_secs = args.timeout_secs.clamp(1, MAX_TIMEOUT_SECS);
117        let max_output_chars = args
118            .max_output_chars
119            .clamp(MIN_OUTPUT_CHARS, MAX_OUTPUT_CHARS);
120        let mut output = format!(
121            "Static analysis: {} command(s), timeout {}s each\n",
122            commands.len(),
123            timeout_secs
124        );
125
126        for command in commands {
127            output.push_str(&format!(
128                "\n## {}\nReason: {}\nCommand: {} {}\n",
129                command.name,
130                command.reason,
131                command.executable,
132                command.args.join(" ")
133            ));
134            output.push_str(
135                &run_analysis_command(&repo_root, &command, timeout_secs, max_output_chars).await,
136            );
137            output.push('\n');
138        }
139
140        Ok(output)
141    }
142}
143
144pub(super) fn select_analysis_commands(
145    repo_root: &Path,
146    analyzer: StaticAnalyzer,
147    command_available: impl Fn(&str) -> bool,
148) -> Vec<AnalysisCommand> {
149    let mut commands = Vec::new();
150    let wants = |candidate| analyzer == StaticAnalyzer::Auto || analyzer == candidate;
151
152    if wants(StaticAnalyzer::Rust)
153        && (analyzer == StaticAnalyzer::Rust || repo_root.join("Cargo.toml").is_file())
154        && command_available("cargo")
155    {
156        commands.push(AnalysisCommand {
157            name: "Rust clippy",
158            executable: "cargo",
159            args: vec![
160                "clippy",
161                "--workspace",
162                "--no-deps",
163                "--message-format",
164                "short",
165            ],
166            reason: if analyzer == StaticAnalyzer::Rust {
167                "Rust analyzer requested"
168            } else {
169                "Cargo.toml detected"
170            },
171        });
172    }
173
174    if wants(StaticAnalyzer::Python)
175        && (analyzer == StaticAnalyzer::Python
176            || has_any(
177                repo_root,
178                &["pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg"],
179            ))
180        && command_available("ruff")
181    {
182        commands.push(AnalysisCommand {
183            name: "Python ruff",
184            executable: "ruff",
185            args: vec!["check", "."],
186            reason: if analyzer == StaticAnalyzer::Python {
187                "Python analyzer requested"
188            } else {
189                "Python project config detected"
190            },
191        });
192    }
193
194    if wants(StaticAnalyzer::Javascript)
195        && (analyzer == StaticAnalyzer::Javascript || repo_root.join("package.json").is_file())
196    {
197        if command_available("biome") {
198            commands.push(AnalysisCommand {
199                name: "JavaScript/TypeScript biome",
200                executable: "biome",
201                args: vec!["check", "."],
202                reason: if analyzer == StaticAnalyzer::Javascript {
203                    "JavaScript analyzer requested and biome is installed"
204                } else {
205                    "package.json detected and biome is installed"
206                },
207            });
208        } else if command_available("oxlint") {
209            commands.push(AnalysisCommand {
210                name: "JavaScript/TypeScript oxlint",
211                executable: "oxlint",
212                args: vec!["."],
213                reason: if analyzer == StaticAnalyzer::Javascript {
214                    "JavaScript analyzer requested and oxlint is installed"
215                } else {
216                    "package.json detected and oxlint is installed"
217                },
218            });
219        }
220    }
221
222    if wants(StaticAnalyzer::Go)
223        && (analyzer == StaticAnalyzer::Go || repo_root.join("go.mod").is_file())
224    {
225        if command_available("golangci-lint") {
226            commands.push(AnalysisCommand {
227                name: "Go golangci-lint",
228                executable: "golangci-lint",
229                args: vec!["run"],
230                reason: if analyzer == StaticAnalyzer::Go {
231                    "Go analyzer requested and golangci-lint is installed"
232                } else {
233                    "go.mod detected and golangci-lint is installed"
234                },
235            });
236        } else if command_available("go") {
237            commands.push(AnalysisCommand {
238                name: "Go vet",
239                executable: "go",
240                args: vec!["vet", "./..."],
241                reason: if analyzer == StaticAnalyzer::Go {
242                    "Go analyzer requested and go is installed"
243                } else {
244                    "go.mod detected and go is installed"
245                },
246            });
247        }
248    }
249
250    commands
251}
252
253pub(super) fn unavailable_analysis_summary(
254    repo_root: &Path,
255    analyzer: StaticAnalyzer,
256    command_available: impl Fn(&str) -> bool,
257) -> Vec<String> {
258    let mut notes = Vec::new();
259    let wants = |candidate| analyzer == StaticAnalyzer::Auto || analyzer == candidate;
260    let python_config = has_any(
261        repo_root,
262        &["pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg"],
263    );
264    let mut applicable_auto_marker = false;
265
266    if wants(StaticAnalyzer::Rust) {
267        let applicable = analyzer == StaticAnalyzer::Rust || repo_root.join("Cargo.toml").is_file();
268        applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
269        if applicable && !command_available("cargo") {
270            notes.push(if analyzer == StaticAnalyzer::Rust {
271                "Rust analyzer requested but cargo is not on PATH.".to_string()
272            } else {
273                "Cargo.toml detected but cargo is not on PATH.".to_string()
274            });
275        }
276    }
277
278    if wants(StaticAnalyzer::Python) {
279        let applicable = analyzer == StaticAnalyzer::Python || python_config;
280        applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
281        if applicable && !command_available("ruff") {
282            notes.push(if analyzer == StaticAnalyzer::Python {
283                "Python analyzer requested but ruff is not on PATH.".to_string()
284            } else {
285                "Python config detected but ruff is not on PATH.".to_string()
286            });
287        }
288    }
289
290    if wants(StaticAnalyzer::Javascript) {
291        let applicable =
292            analyzer == StaticAnalyzer::Javascript || repo_root.join("package.json").is_file();
293        applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
294        if applicable && !command_available("biome") && !command_available("oxlint") {
295            notes.push(if analyzer == StaticAnalyzer::Javascript {
296                "JavaScript analyzer requested but biome and oxlint are not on PATH.".to_string()
297            } else {
298                "package.json detected but biome and oxlint are not on PATH.".to_string()
299            });
300        }
301    }
302
303    if wants(StaticAnalyzer::Go) {
304        let applicable = analyzer == StaticAnalyzer::Go || repo_root.join("go.mod").is_file();
305        applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
306        if applicable && !command_available("golangci-lint") && !command_available("go") {
307            notes.push(if analyzer == StaticAnalyzer::Go {
308                "Go analyzer requested but golangci-lint and go are not on PATH.".to_string()
309            } else {
310                "go.mod detected but golangci-lint and go are not on PATH.".to_string()
311            });
312        }
313    }
314
315    if notes.is_empty() && analyzer == StaticAnalyzer::Auto && !applicable_auto_marker {
316        notes.push("No matching project markers detected for auto mode.".to_string());
317    }
318
319    notes
320}
321
322fn has_any(repo_root: &Path, names: &[&str]) -> bool {
323    names.iter().any(|name| repo_root.join(name).is_file())
324}
325
326fn command_available(command: &str) -> bool {
327    std::env::var_os("PATH").is_some_and(|paths| {
328        std::env::split_paths(&paths).any(|path| executable_exists(&path.join(command)))
329    })
330}
331
332pub(super) fn executable_exists(path: &Path) -> bool {
333    #[cfg(windows)]
334    {
335        if path.is_file() {
336            return true;
337        }
338
339        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
340            return false;
341        };
342        let pathext =
343            std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
344        pathext
345            .split(';')
346            .filter(|extension| !extension.is_empty())
347            .any(|extension| path.with_file_name(format!("{name}{extension}")).is_file())
348    }
349
350    #[cfg(unix)]
351    {
352        use std::os::unix::fs::PermissionsExt;
353
354        path.metadata()
355            .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
356    }
357
358    #[cfg(not(any(unix, windows)))]
359    {
360        path.is_file()
361    }
362}
363
364async fn run_analysis_command(
365    repo_root: &Path,
366    command: &AnalysisCommand,
367    timeout_secs: u64,
368    max_output_chars: usize,
369) -> String {
370    let mut process = Command::new(command.executable);
371    process.args(&command.args);
372    process.current_dir(repo_root);
373    process.stdin(Stdio::null());
374    process.stdout(Stdio::piped());
375    process.stderr(Stdio::piped());
376    process.kill_on_drop(true);
377
378    match timeout(Duration::from_secs(timeout_secs), process.output()).await {
379        Ok(Ok(output)) => format_command_output(output.status.success(), &output, max_output_chars),
380        Ok(Err(error)) => format!("Failed to run {}: {error}\n", command.executable),
381        Err(_) => format!("Timed out after {timeout_secs}s\n"),
382    }
383}
384
385fn format_command_output(success: bool, output: &std::process::Output, max_chars: usize) -> String {
386    let status = if success { "passed" } else { "failed" };
387    let stdout = String::from_utf8_lossy(&output.stdout);
388    let stderr = String::from_utf8_lossy(&output.stderr);
389    let combined = format!("Status: {status}\n\nstderr:\n{stderr}\n\nstdout:\n{stdout}");
390    truncate_chars(&combined, max_chars)
391}
392
393fn truncate_chars(text: &str, max_chars: usize) -> String {
394    let mut chars = text.chars();
395    let mut truncated = chars.by_ref().take(max_chars).collect::<String>();
396    if chars.next().is_none() {
397        return truncated;
398    }
399
400    truncated.push_str("\n[static_analysis output truncated]");
401    truncated
402}