//! Static analysis tool for agent review context.
use anyhow::Result;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tokio::time::timeout;
use super::common::{current_repo_root, parameters_schema};
crate::define_tool_error!(StaticAnalysisError);
const DEFAULT_TIMEOUT_SECS: u64 = 300;
const DEFAULT_MAX_OUTPUT_CHARS: usize = 12_000;
const MIN_OUTPUT_CHARS: usize = 512;
const MAX_TIMEOUT_SECS: u64 = 600;
const MAX_OUTPUT_CHARS: usize = 40_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticAnalysis;
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StaticAnalyzer {
#[default]
Auto,
Rust,
Python,
Javascript,
Go,
}
impl fmt::Display for StaticAnalyzer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => write!(f, "auto"),
Self::Rust => write!(f, "rust"),
Self::Python => write!(f, "python"),
Self::Javascript => write!(f, "javascript"),
Self::Go => write!(f, "go"),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct StaticAnalysisArgs {
#[serde(default)]
pub analyzer: StaticAnalyzer,
#[serde(default = "default_timeout_secs")]
#[schemars(
description = "Seconds to wait per analysis command. Values are clamped to 1..600."
)]
pub timeout_secs: u64,
#[serde(default = "default_max_output_chars")]
#[schemars(
description = "Maximum characters to return per command. Values are clamped to 512..40000."
)]
pub max_output_chars: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct AnalysisCommand {
pub(super) name: &'static str,
pub(super) executable: &'static str,
pub(super) args: Vec<&'static str>,
pub(super) reason: &'static str,
}
impl Default for StaticAnalysis {
fn default() -> Self {
Self
}
}
fn default_timeout_secs() -> u64 {
DEFAULT_TIMEOUT_SECS
}
fn default_max_output_chars() -> usize {
DEFAULT_MAX_OUTPUT_CHARS
}
impl Tool for StaticAnalysis {
const NAME: &'static str = "static_analysis";
type Error = StaticAnalysisError;
type Args = StaticAnalysisArgs;
type Output = String;
async fn definition(&self, _: String) -> ToolDefinition {
ToolDefinition {
name: Self::NAME.to_string(),
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(),
parameters: parameters_schema::<StaticAnalysisArgs>(),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let repo_root = current_repo_root()?;
let commands = select_analysis_commands(&repo_root, args.analyzer, command_available);
if commands.is_empty() {
let availability =
unavailable_analysis_summary(&repo_root, args.analyzer, command_available);
return Ok(format!(
"No installed static analysis command found for `{}`. Supported direct commands: cargo, ruff, biome, oxlint, golangci-lint, go.\n{}",
args.analyzer,
availability.join("\n")
));
}
let timeout_secs = args.timeout_secs.clamp(1, MAX_TIMEOUT_SECS);
let max_output_chars = args
.max_output_chars
.clamp(MIN_OUTPUT_CHARS, MAX_OUTPUT_CHARS);
let mut output = format!(
"Static analysis: {} command(s), timeout {}s each\n",
commands.len(),
timeout_secs
);
for command in commands {
output.push_str(&format!(
"\n## {}\nReason: {}\nCommand: {} {}\n",
command.name,
command.reason,
command.executable,
command.args.join(" ")
));
output.push_str(
&run_analysis_command(&repo_root, &command, timeout_secs, max_output_chars).await,
);
output.push('\n');
}
Ok(output)
}
}
pub(super) fn select_analysis_commands(
repo_root: &Path,
analyzer: StaticAnalyzer,
command_available: impl Fn(&str) -> bool,
) -> Vec<AnalysisCommand> {
let mut commands = Vec::new();
let wants = |candidate| analyzer == StaticAnalyzer::Auto || analyzer == candidate;
if wants(StaticAnalyzer::Rust)
&& (analyzer == StaticAnalyzer::Rust || repo_root.join("Cargo.toml").is_file())
&& command_available("cargo")
{
commands.push(AnalysisCommand {
name: "Rust clippy",
executable: "cargo",
args: vec![
"clippy",
"--workspace",
"--no-deps",
"--message-format",
"short",
],
reason: if analyzer == StaticAnalyzer::Rust {
"Rust analyzer requested"
} else {
"Cargo.toml detected"
},
});
}
if wants(StaticAnalyzer::Python)
&& (analyzer == StaticAnalyzer::Python
|| has_any(
repo_root,
&["pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg"],
))
&& command_available("ruff")
{
commands.push(AnalysisCommand {
name: "Python ruff",
executable: "ruff",
args: vec!["check", "."],
reason: if analyzer == StaticAnalyzer::Python {
"Python analyzer requested"
} else {
"Python project config detected"
},
});
}
if wants(StaticAnalyzer::Javascript)
&& (analyzer == StaticAnalyzer::Javascript || repo_root.join("package.json").is_file())
{
if command_available("biome") {
commands.push(AnalysisCommand {
name: "JavaScript/TypeScript biome",
executable: "biome",
args: vec!["check", "."],
reason: if analyzer == StaticAnalyzer::Javascript {
"JavaScript analyzer requested and biome is installed"
} else {
"package.json detected and biome is installed"
},
});
} else if command_available("oxlint") {
commands.push(AnalysisCommand {
name: "JavaScript/TypeScript oxlint",
executable: "oxlint",
args: vec!["."],
reason: if analyzer == StaticAnalyzer::Javascript {
"JavaScript analyzer requested and oxlint is installed"
} else {
"package.json detected and oxlint is installed"
},
});
}
}
if wants(StaticAnalyzer::Go)
&& (analyzer == StaticAnalyzer::Go || repo_root.join("go.mod").is_file())
{
if command_available("golangci-lint") {
commands.push(AnalysisCommand {
name: "Go golangci-lint",
executable: "golangci-lint",
args: vec!["run"],
reason: if analyzer == StaticAnalyzer::Go {
"Go analyzer requested and golangci-lint is installed"
} else {
"go.mod detected and golangci-lint is installed"
},
});
} else if command_available("go") {
commands.push(AnalysisCommand {
name: "Go vet",
executable: "go",
args: vec!["vet", "./..."],
reason: if analyzer == StaticAnalyzer::Go {
"Go analyzer requested and go is installed"
} else {
"go.mod detected and go is installed"
},
});
}
}
commands
}
pub(super) fn unavailable_analysis_summary(
repo_root: &Path,
analyzer: StaticAnalyzer,
command_available: impl Fn(&str) -> bool,
) -> Vec<String> {
let mut notes = Vec::new();
let wants = |candidate| analyzer == StaticAnalyzer::Auto || analyzer == candidate;
let python_config = has_any(
repo_root,
&["pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg"],
);
let mut applicable_auto_marker = false;
if wants(StaticAnalyzer::Rust) {
let applicable = analyzer == StaticAnalyzer::Rust || repo_root.join("Cargo.toml").is_file();
applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
if applicable && !command_available("cargo") {
notes.push(if analyzer == StaticAnalyzer::Rust {
"Rust analyzer requested but cargo is not on PATH.".to_string()
} else {
"Cargo.toml detected but cargo is not on PATH.".to_string()
});
}
}
if wants(StaticAnalyzer::Python) {
let applicable = analyzer == StaticAnalyzer::Python || python_config;
applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
if applicable && !command_available("ruff") {
notes.push(if analyzer == StaticAnalyzer::Python {
"Python analyzer requested but ruff is not on PATH.".to_string()
} else {
"Python config detected but ruff is not on PATH.".to_string()
});
}
}
if wants(StaticAnalyzer::Javascript) {
let applicable =
analyzer == StaticAnalyzer::Javascript || repo_root.join("package.json").is_file();
applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
if applicable && !command_available("biome") && !command_available("oxlint") {
notes.push(if analyzer == StaticAnalyzer::Javascript {
"JavaScript analyzer requested but biome and oxlint are not on PATH.".to_string()
} else {
"package.json detected but biome and oxlint are not on PATH.".to_string()
});
}
}
if wants(StaticAnalyzer::Go) {
let applicable = analyzer == StaticAnalyzer::Go || repo_root.join("go.mod").is_file();
applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
if applicable && !command_available("golangci-lint") && !command_available("go") {
notes.push(if analyzer == StaticAnalyzer::Go {
"Go analyzer requested but golangci-lint and go are not on PATH.".to_string()
} else {
"go.mod detected but golangci-lint and go are not on PATH.".to_string()
});
}
}
if notes.is_empty() && analyzer == StaticAnalyzer::Auto && !applicable_auto_marker {
notes.push("No matching project markers detected for auto mode.".to_string());
}
notes
}
fn has_any(repo_root: &Path, names: &[&str]) -> bool {
names.iter().any(|name| repo_root.join(name).is_file())
}
fn command_available(command: &str) -> bool {
std::env::var_os("PATH").is_some_and(|paths| {
std::env::split_paths(&paths).any(|path| executable_exists(&path.join(command)))
})
}
pub(super) fn executable_exists(path: &Path) -> bool {
#[cfg(windows)]
{
if path.is_file() {
return true;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
let pathext =
std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
pathext
.split(';')
.filter(|extension| !extension.is_empty())
.any(|extension| path.with_file_name(format!("{name}{extension}")).is_file())
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(any(unix, windows)))]
{
path.is_file()
}
}
async fn run_analysis_command(
repo_root: &Path,
command: &AnalysisCommand,
timeout_secs: u64,
max_output_chars: usize,
) -> String {
let mut process = Command::new(command.executable);
process.args(&command.args);
process.current_dir(repo_root);
process.stdin(Stdio::null());
process.stdout(Stdio::piped());
process.stderr(Stdio::piped());
process.kill_on_drop(true);
match timeout(Duration::from_secs(timeout_secs), process.output()).await {
Ok(Ok(output)) => format_command_output(output.status.success(), &output, max_output_chars),
Ok(Err(error)) => format!("Failed to run {}: {error}\n", command.executable),
Err(_) => format!("Timed out after {timeout_secs}s\n"),
}
}
fn format_command_output(success: bool, output: &std::process::Output, max_chars: usize) -> String {
let status = if success { "passed" } else { "failed" };
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("Status: {status}\n\nstderr:\n{stderr}\n\nstdout:\n{stdout}");
truncate_chars(&combined, max_chars)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
let mut chars = text.chars();
let mut truncated = chars.by_ref().take(max_chars).collect::<String>();
if chars.next().is_none() {
return truncated;
}
truncated.push_str("\n[static_analysis output truncated]");
truncated
}