mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(core/exec): enforce dry-run guard for formatter commands
This commit is contained in:
+104
-9
@@ -454,6 +454,7 @@ use codex_protocol::models::ShellToolCallParams;
|
||||
use crate::openai_model_info::get_model_info;
|
||||
use crate::openai_tools::ToolsConfig;
|
||||
use crate::openai_tools::get_openai_tools;
|
||||
use crate::dry_run_guard::{analyze_command, DryRunAnalysis, DryRunDisposition, DryRunGuardState};
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::plan_tool::handle_update_plan;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
@@ -615,6 +616,7 @@ struct State {
|
||||
event_seq_by_sub_id: HashMap<String, u64>,
|
||||
/// 1-based ordinal of the current HTTP request attempt in this session.
|
||||
request_ordinal: u64,
|
||||
dry_run_guard: DryRunGuardState,
|
||||
}
|
||||
|
||||
/// Buffers partial turn progress produced during a single HTTP streaming attempt.
|
||||
@@ -1225,14 +1227,18 @@ impl Session {
|
||||
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index, attempt_req)
|
||||
.await;
|
||||
|
||||
let result = process_exec_tool_call(
|
||||
exec_args.params,
|
||||
exec_args.sandbox_type,
|
||||
exec_args.sandbox_policy,
|
||||
exec_args.codex_linux_sandbox_exe,
|
||||
exec_args.stdout_stream,
|
||||
)
|
||||
.await;
|
||||
let ExecInvokeArgs { params, sandbox_type, sandbox_policy, codex_linux_sandbox_exe, stdout_stream } = exec_args;
|
||||
let tracking_command = params.command.clone();
|
||||
let dry_run_analysis = analyze_command(&tracking_command);
|
||||
|
||||
let result = process_exec_tool_call(
|
||||
params,
|
||||
sandbox_type,
|
||||
sandbox_policy,
|
||||
codex_linux_sandbox_exe,
|
||||
stdout_stream,
|
||||
)
|
||||
.await;
|
||||
|
||||
let output_stderr;
|
||||
let borrowed: &ExecToolCallOutput = match &result {
|
||||
@@ -1262,6 +1268,11 @@ impl Session {
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(analysis) = dry_run_analysis.as_ref() {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.dry_run_guard.note_execution(analysis);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1512,6 +1523,7 @@ impl State {
|
||||
// Preserve request_ordinal so reconfigurations (e.g., /reasoning)
|
||||
// do not reset provider ordering mid-session.
|
||||
request_ordinal: self.request_ordinal,
|
||||
dry_run_guard: self.dry_run_guard.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -5154,6 +5166,27 @@ async fn handle_container_exec_with_params(
|
||||
}
|
||||
}
|
||||
|
||||
fn guidance_for_dry_run_guard(
|
||||
analysis: &DryRunAnalysis,
|
||||
original_label: &str,
|
||||
original_value: &str,
|
||||
resend_exact_argv: Vec<String>,
|
||||
) -> String {
|
||||
let suggested_confirm = serde_json::to_string(&resend_exact_argv)
|
||||
.unwrap_or_else(|_| "<failed to serialize suggested argv>".to_string());
|
||||
let suggested_dry_run = analysis
|
||||
.suggested_dry_run()
|
||||
.unwrap_or_else(|| "<no canonical dry-run variant; remove mutating flags or use confirm:>".to_string());
|
||||
format!(
|
||||
"Blocked {} without a prior dry run. Run the dry-run variant first or resend with 'confirm:' if explicitly requested.\n\n{}: {}\nresend_exact_argv: {}\nsuggested_dry_run: {}",
|
||||
analysis.display_name(),
|
||||
original_label,
|
||||
original_value,
|
||||
suggested_confirm,
|
||||
suggested_dry_run
|
||||
)
|
||||
}
|
||||
|
||||
// If the argv is a shell wrapper, analyze and optionally strip `confirm:`.
|
||||
let mut params = params;
|
||||
let mut seq_hint_for_exec = seq_hint;
|
||||
@@ -5219,6 +5252,37 @@ async fn handle_container_exec_with_params(
|
||||
params.command[script_index] = without_prefix;
|
||||
}
|
||||
|
||||
let dry_run_analysis = analyze_command(¶ms.command);
|
||||
if !has_confirm_prefix {
|
||||
if let Some(analysis) = dry_run_analysis.as_ref() {
|
||||
if analysis.disposition == DryRunDisposition::Mutating {
|
||||
let needs_dry_run = {
|
||||
let state = sess.state.lock().unwrap();
|
||||
!state.dry_run_guard.has_recent_dry_run(analysis.key)
|
||||
};
|
||||
if needs_dry_run {
|
||||
let mut argv_confirm = params.command.clone();
|
||||
argv_confirm[script_index] = format!("confirm: {}", params.command[script_index].trim_start());
|
||||
let guidance = guidance_for_dry_run_guard(
|
||||
analysis,
|
||||
"original_script",
|
||||
¶ms.command[script_index],
|
||||
argv_confirm,
|
||||
);
|
||||
|
||||
sess
|
||||
.notify_background_event(&sub_id, format!("Command guard: {}", guidance.clone()))
|
||||
.await;
|
||||
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload { content: guidance, success: None },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect an embedded `apply_patch <<EOF ... EOF` in a larger script and split it out
|
||||
// so the UI can render a distinct "Updated" block before the "Run" block.
|
||||
//
|
||||
@@ -5363,8 +5427,8 @@ async fn handle_container_exec_with_params(
|
||||
|
||||
// If no shell wrapper, perform a lightweight argv inspection for sensitive git commands.
|
||||
if extract_shell_script_from_wrapper(¶ms.command).is_none() {
|
||||
let joined = params.command.join(" ");
|
||||
if !sess.confirm_guard.is_empty() {
|
||||
let joined = params.command.join(" ");
|
||||
if let Some(pattern) = sess.confirm_guard.matched_pattern(&joined) {
|
||||
let suggested = serde_json::to_string(&vec![
|
||||
"bash".to_string(),
|
||||
@@ -5389,6 +5453,37 @@ async fn handle_container_exec_with_params(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(analysis) = analyze_command(¶ms.command) {
|
||||
if analysis.disposition == DryRunDisposition::Mutating {
|
||||
let needs_dry_run = {
|
||||
let state = sess.state.lock().unwrap();
|
||||
!state.dry_run_guard.has_recent_dry_run(analysis.key)
|
||||
};
|
||||
if needs_dry_run {
|
||||
let resend = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
format!("confirm: {}", joined),
|
||||
];
|
||||
let guidance = guidance_for_dry_run_guard(
|
||||
&analysis,
|
||||
"original_argv",
|
||||
&format!("{:?}", params.command),
|
||||
resend,
|
||||
);
|
||||
|
||||
sess
|
||||
.notify_background_event(&sub_id, format!("Command guard: {}", guidance.clone()))
|
||||
.await;
|
||||
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload { content: guidance, success: None },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_tok2(t: &str) -> &str { t.trim_matches(|c| matches!(c, '(' | ')' | '{' | '}' | '\'' | '"')) }
|
||||
let mut i = 0usize;
|
||||
// Skip env assignments and simple wrappers at the front
|
||||
|
||||
@@ -0,0 +1,807 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use shlex::split as shlex_split;
|
||||
|
||||
use crate::parse_command::{parse_command_impl, ParsedCommand};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum DryRunGuardKey {
|
||||
CargoFmt,
|
||||
CargoFix,
|
||||
CargoClippyFix,
|
||||
Rustfmt,
|
||||
PackageLint(PackageManager),
|
||||
PackageFormat(PackageManager),
|
||||
EslintFix,
|
||||
PrettierWrite,
|
||||
Black,
|
||||
RuffFormat,
|
||||
Isort,
|
||||
Gofmt,
|
||||
Rustywind,
|
||||
DartFormat,
|
||||
SwiftFormat,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum PackageManager {
|
||||
Npm,
|
||||
Pnpm,
|
||||
Yarn,
|
||||
}
|
||||
|
||||
impl PackageManager {
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
PackageManager::Npm => "npm",
|
||||
PackageManager::Pnpm => "pnpm",
|
||||
PackageManager::Yarn => "yarn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DryRunDisposition {
|
||||
DryRun,
|
||||
Mutating,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DryRunAnalysis {
|
||||
pub key: DryRunGuardKey,
|
||||
pub disposition: DryRunDisposition,
|
||||
tokens: Vec<String>,
|
||||
display_name: String,
|
||||
custom_suggested_dry_run: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DryRunGuardState {
|
||||
command_counter: u64,
|
||||
last_dry_run: HashMap<DryRunGuardKey, u64>,
|
||||
last_mutating: HashMap<DryRunGuardKey, u64>,
|
||||
}
|
||||
|
||||
impl DryRunGuardState {
|
||||
pub fn has_recent_dry_run(&self, key: DryRunGuardKey) -> bool {
|
||||
match self.last_dry_run.get(&key).copied() {
|
||||
Some(dry_seq) => {
|
||||
let last_mut = self.last_mutating.get(&key).copied().unwrap_or(0);
|
||||
dry_seq > last_mut
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note_execution(&mut self, analysis: &DryRunAnalysis) {
|
||||
self.command_counter = self.command_counter.saturating_add(1);
|
||||
let seq = self.command_counter;
|
||||
match analysis.disposition {
|
||||
DryRunDisposition::DryRun => {
|
||||
self.last_dry_run.insert(analysis.key, seq);
|
||||
}
|
||||
DryRunDisposition::Mutating => {
|
||||
self.last_mutating.insert(analysis.key, seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DryRunAnalysis {
|
||||
pub fn suggested_dry_run(&self) -> Option<String> {
|
||||
if let Some(custom) = &self.custom_suggested_dry_run {
|
||||
return Some(custom.clone());
|
||||
}
|
||||
|
||||
match self.key {
|
||||
DryRunGuardKey::CargoFmt => self.suggest_cargo_fmt_dry_run(),
|
||||
DryRunGuardKey::CargoFix => self.suggest_cargo_fix_dry_run(),
|
||||
DryRunGuardKey::CargoClippyFix => self.suggest_cargo_clippy_dry_run(),
|
||||
DryRunGuardKey::Rustfmt => self.suggest_rustfmt_dry_run(),
|
||||
DryRunGuardKey::PackageLint(manager) => self.suggest_package_script_dry_run(manager, "--dry-run", Some("--check")),
|
||||
DryRunGuardKey::PackageFormat(manager) => self.suggest_package_script_dry_run(manager, "--check", Some("--dry-run")),
|
||||
DryRunGuardKey::EslintFix => self.suggest_eslint_fix_dry_run(),
|
||||
DryRunGuardKey::PrettierWrite => self.suggest_prettier_write_dry_run(),
|
||||
DryRunGuardKey::Black => self.suggest_append_flag("--check"),
|
||||
DryRunGuardKey::RuffFormat => self.suggest_append_flag("--check"),
|
||||
DryRunGuardKey::Isort => self.suggest_append_flag("--check-only"),
|
||||
DryRunGuardKey::Gofmt => self.suggest_gofmt_dry_run(),
|
||||
DryRunGuardKey::Rustywind => self.suggest_append_flag("--check"),
|
||||
DryRunGuardKey::DartFormat => self.suggest_append_flag("--dry-run"),
|
||||
DryRunGuardKey::SwiftFormat => self.suggest_swiftformat_dry_run(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
&self.display_name
|
||||
}
|
||||
|
||||
fn suggest_cargo_fmt_dry_run(&self) -> Option<String> {
|
||||
let mut out = self.tokens.clone();
|
||||
if let Some(pos) = out.iter().position(|t| t == "--") {
|
||||
if !out.iter().skip(pos + 1).any(|t| equal_ignore_case(t, "--check")) {
|
||||
out.insert(pos + 1, "--check".to_string());
|
||||
}
|
||||
} else {
|
||||
out.push("--".to_string());
|
||||
out.push("--check".to_string());
|
||||
}
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_cargo_fix_dry_run(&self) -> Option<String> {
|
||||
let mut out = self.tokens.clone();
|
||||
if !out.iter().any(|t| equal_ignore_case(t, "--dry-run")) {
|
||||
let insert_pos = out.iter().position(|t| t == "--").unwrap_or(out.len());
|
||||
out.insert(insert_pos, "--dry-run".to_string());
|
||||
}
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_cargo_clippy_dry_run(&self) -> Option<String> {
|
||||
let mut removed = false;
|
||||
let filtered: Vec<String> = self
|
||||
.tokens
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
if !removed && equal_ignore_case(t, "--fix") {
|
||||
removed = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
Some(filtered.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_rustfmt_dry_run(&self) -> Option<String> {
|
||||
let mut out = self.tokens.clone();
|
||||
if !out.iter().any(|t| equal_ignore_case(t, "--check")) {
|
||||
out.push("--check".to_string());
|
||||
}
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_package_script_dry_run(
|
||||
&self,
|
||||
_manager: PackageManager,
|
||||
preferred_flag: &str,
|
||||
fallback_flag: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let mut out = self.tokens.clone();
|
||||
if !out.iter().any(|t| {
|
||||
equal_ignore_case(t, preferred_flag)
|
||||
|| fallback_flag.is_some_and(|flag| equal_ignore_case(t, flag))
|
||||
}) {
|
||||
out.push("--".to_string());
|
||||
out.push(preferred_flag.to_string());
|
||||
}
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_eslint_fix_dry_run(&self) -> Option<String> {
|
||||
if self
|
||||
.tokens
|
||||
.iter()
|
||||
.any(|t| equal_ignore_case(t, "--fix-dry-run") || equal_ignore_case(t, "--dry-run"))
|
||||
{
|
||||
return Some(self.tokens.join(" "));
|
||||
}
|
||||
let mut out = self.tokens.clone();
|
||||
let insert_pos = out.iter().position(|t| t == "--").unwrap_or(out.len());
|
||||
out.insert(insert_pos, "--fix-dry-run".to_string());
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_prettier_write_dry_run(&self) -> Option<String> {
|
||||
if self
|
||||
.tokens
|
||||
.iter()
|
||||
.any(|t| equal_ignore_case(t, "--check") || equal_ignore_case(t, "--list-different"))
|
||||
{
|
||||
return Some(self.tokens.join(" "));
|
||||
}
|
||||
|
||||
let mut out = self.tokens.clone();
|
||||
for token in out.iter_mut() {
|
||||
if equal_ignore_case(token.as_str(), "--write") || token.as_str() == "-w" {
|
||||
*token = "--check".to_string();
|
||||
return Some(out.join(" "));
|
||||
}
|
||||
}
|
||||
out.push("--check".to_string());
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_append_flag(&self, flag: &str) -> Option<String> {
|
||||
if self.tokens.iter().any(|t| equal_ignore_case(t, flag)) {
|
||||
return Some(self.tokens.join(" "));
|
||||
}
|
||||
let mut out = self.tokens.clone();
|
||||
out.push(flag.to_string());
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_gofmt_dry_run(&self) -> Option<String> {
|
||||
let mut out = Vec::with_capacity(self.tokens.len());
|
||||
let mut replaced = false;
|
||||
for token in &self.tokens {
|
||||
if !replaced && (token == "-w" || token.starts_with("-w=")) {
|
||||
out.push("-d".to_string());
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
out.push(token.clone());
|
||||
}
|
||||
if !replaced {
|
||||
out.push("-d".to_string());
|
||||
}
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
fn suggest_swiftformat_dry_run(&self) -> Option<String> {
|
||||
if self
|
||||
.tokens
|
||||
.iter()
|
||||
.any(|t| equal_ignore_case(t, "--lint") || equal_ignore_case(t, "--dryrun"))
|
||||
{
|
||||
return Some(self.tokens.join(" "));
|
||||
}
|
||||
let mut out = self.tokens.clone();
|
||||
out.push("--lint".to_string());
|
||||
Some(out.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
fn first_command_tokens(argv: &[String]) -> Option<Vec<String>> {
|
||||
let parsed = parse_command_impl(argv);
|
||||
let first = parsed.into_iter().next()?;
|
||||
let cmd = match first {
|
||||
ParsedCommand::Read { cmd, .. }
|
||||
| ParsedCommand::ListFiles { cmd, .. }
|
||||
| ParsedCommand::Search { cmd, .. }
|
||||
| ParsedCommand::ReadCommand { cmd }
|
||||
| ParsedCommand::Unknown { cmd } => cmd,
|
||||
};
|
||||
match shlex_split(&cmd) {
|
||||
Some(tokens) => Some(tokens),
|
||||
None => Some(cmd.split_whitespace().map(|s| s.to_string()).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_wrappers(tokens: &[String]) -> Vec<String> {
|
||||
let mut idx = 0usize;
|
||||
while idx < tokens.len() {
|
||||
let token = tokens[idx].as_str();
|
||||
if is_env_assignment(token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if is_wrapper(token) {
|
||||
idx += 1;
|
||||
while idx < tokens.len() && tokens[idx].starts_with('-') {
|
||||
idx += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
tokens[idx..].to_vec()
|
||||
}
|
||||
|
||||
fn is_env_assignment(token: &str) -> bool {
|
||||
token.contains('=') && !token.starts_with('-') && !token.starts_with('=')
|
||||
}
|
||||
|
||||
fn is_wrapper(token: &str) -> bool {
|
||||
matches!(token, "env" | "sudo" | "command" | "time" | "nohup" | "nice")
|
||||
}
|
||||
|
||||
fn command_basename(token: &str) -> String {
|
||||
let trimmed = token.trim_matches('"').trim_matches('\'');
|
||||
Path::new(trimmed)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(trimmed)
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn equal_ignore_case<S: AsRef<str>, T: AsRef<str>>(left: S, right: T) -> bool {
|
||||
left.as_ref().eq_ignore_ascii_case(right.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn to_vec(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
fn analyze(raw: &str) -> DryRunAnalysis {
|
||||
analyze_command(&to_vec(&["bash", "-lc", raw])).expect("analysis")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cargo_fmt_mutating() {
|
||||
let analysis = analyze("cargo fmt");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::CargoFmt);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cargo_fmt_check() {
|
||||
let analysis = analyze("cargo fmt -- --check");
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cargo_fix_mutating() {
|
||||
let analysis = analyze("cargo fix");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::CargoFix);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cargo_fix_dry_run() {
|
||||
let analysis = analyze("cargo fix --dry-run");
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cargo_clippy_fix() {
|
||||
let analysis = analyze("cargo clippy --fix");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::CargoClippyFix);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rustfmt_check() {
|
||||
let analysis = analyze("rustfmt --check src/lib.rs");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::Rustfmt);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_npm_lint_without_dry_run() {
|
||||
let analysis = analyze("npm run lint");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::PackageLint(PackageManager::Npm));
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_npm_lint_with_dry_run() {
|
||||
let analysis = analyze("npm run lint -- --dry-run");
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_package_format_script() {
|
||||
let analysis = analyze("yarn format");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::PackageFormat(PackageManager::Yarn));
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_eslint_fix() {
|
||||
let analysis = analyze("npx eslint --fix src");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::EslintFix);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_prettier_write() {
|
||||
let analysis = analyze("pnpm exec prettier --write src/index.ts");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::PrettierWrite);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_black_check() {
|
||||
let analysis = analyze("black --check app.py");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::Black);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_black_mutating() {
|
||||
let analysis = analyze("black app.py");
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_ruff_format() {
|
||||
let analysis = analyze("ruff format src");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::RuffFormat);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_gofmt_w() {
|
||||
let analysis = analyze("gofmt -w main.go");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::Gofmt);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_dart_format() {
|
||||
let analysis = analyze("dart format lib");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::DartFormat);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::Mutating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_swiftformat_lint() {
|
||||
let analysis = analyze("swiftformat --lint Sources");
|
||||
assert_eq!(analysis.key, DryRunGuardKey::SwiftFormat);
|
||||
assert_eq!(analysis.disposition, DryRunDisposition::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_state_requires_new_check() {
|
||||
let mut state = DryRunGuardState::default();
|
||||
let mut analysis = analyze("cargo fmt -- --check");
|
||||
state.note_execution(&analysis);
|
||||
assert!(state.has_recent_dry_run(DryRunGuardKey::CargoFmt));
|
||||
|
||||
analysis = analyze("cargo fmt");
|
||||
state.note_execution(&analysis);
|
||||
assert!(!state.has_recent_dry_run(DryRunGuardKey::CargoFmt));
|
||||
}
|
||||
}
|
||||
pub fn analyze_command(command: &[String]) -> Option<DryRunAnalysis> {
|
||||
let first_tokens = first_command_tokens(command)?;
|
||||
if first_tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tokens = strip_wrappers(&first_tokens);
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let analysis = analyze_tokens(&tokens)?;
|
||||
Some(DryRunAnalysis {
|
||||
key: analysis.key,
|
||||
disposition: analysis.disposition,
|
||||
tokens,
|
||||
display_name: analysis.display_name,
|
||||
custom_suggested_dry_run: analysis.suggested_dry_run,
|
||||
})
|
||||
}
|
||||
|
||||
struct InternalAnalysis {
|
||||
key: DryRunGuardKey,
|
||||
disposition: DryRunDisposition,
|
||||
display_name: String,
|
||||
suggested_dry_run: Option<String>,
|
||||
}
|
||||
|
||||
fn analyze_tokens(tokens: &[String]) -> Option<InternalAnalysis> {
|
||||
analyze_cargo(tokens)
|
||||
.or_else(|| analyze_rustfmt(tokens))
|
||||
.or_else(|| analyze_package_manager(tokens, PackageManager::Npm))
|
||||
.or_else(|| analyze_package_manager(tokens, PackageManager::Pnpm))
|
||||
.or_else(|| analyze_package_manager(tokens, PackageManager::Yarn))
|
||||
.or_else(|| analyze_direct_formatter(tokens))
|
||||
}
|
||||
|
||||
fn analyze_cargo(tokens: &[String]) -> Option<InternalAnalysis> {
|
||||
if tokens.is_empty() || !equal_ignore_case(&tokens[0], "cargo") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut idx = 1usize;
|
||||
while idx < tokens.len() && tokens[idx].starts_with('+') {
|
||||
idx += 1;
|
||||
}
|
||||
if idx >= tokens.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sub = command_basename(&tokens[idx]);
|
||||
let args = if idx + 1 < tokens.len() { &tokens[idx + 1..] } else { &[] };
|
||||
|
||||
match sub.as_str() {
|
||||
"fmt" => {
|
||||
let is_dry = slice_contains_flag(args, "--check");
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::CargoFmt,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "cargo fmt".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"fix" => {
|
||||
let is_dry = slice_contains_flag(args, "--dry-run");
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::CargoFix,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "cargo fix".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"clippy" => {
|
||||
let has_fix = slice_contains_flag(args, "--fix");
|
||||
if !has_fix {
|
||||
return None;
|
||||
}
|
||||
let is_dry = slice_contains_flag(args, "--dry-run");
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::CargoClippyFix,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "cargo clippy --fix".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn analyze_rustfmt(tokens: &[String]) -> Option<InternalAnalysis> {
|
||||
if tokens.is_empty() || !equal_ignore_case(&tokens[0], "rustfmt") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let args = if tokens.len() > 1 { &tokens[1..] } else { &[] };
|
||||
let is_dry = slice_contains_flag(args, "--check");
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::Rustfmt,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "rustfmt".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn analyze_package_manager(tokens: &[String], manager: PackageManager) -> Option<InternalAnalysis> {
|
||||
if tokens.is_empty() || !equal_ignore_case(&tokens[0], manager.label()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if tokens.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (script_idx, script) = locate_package_script(tokens, manager)?;
|
||||
if script.eq_ignore_ascii_case("exec") || script.eq_ignore_ascii_case("dlx") {
|
||||
return analyze_direct_formatter(tokens);
|
||||
}
|
||||
|
||||
let script_lower = script.to_ascii_lowercase();
|
||||
let args = if script_idx + 1 < tokens.len() { &tokens[script_idx + 1..] } else { &[] };
|
||||
|
||||
let (key, display_name) = if script_lower.contains("lint") {
|
||||
(
|
||||
DryRunGuardKey::PackageLint(manager),
|
||||
format!("{} {}", manager.label(), script),
|
||||
)
|
||||
} else if script_lower.contains("format") || script_lower.contains("fmt") || script_lower.contains("prettier") {
|
||||
(
|
||||
DryRunGuardKey::PackageFormat(manager),
|
||||
format!("{} {}", manager.label(), script),
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let is_dry = args.iter().any(|arg| {
|
||||
let lower = arg.to_ascii_lowercase();
|
||||
equal_ignore_case(arg, "--check")
|
||||
|| equal_ignore_case(arg, "--dry-run")
|
||||
|| equal_ignore_case(arg, "--no-fix")
|
||||
|| lower.contains("fix=false")
|
||||
|| lower.contains("write=false")
|
||||
|| lower.contains("mode=check")
|
||||
});
|
||||
|
||||
Some(InternalAnalysis {
|
||||
key,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name,
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn locate_package_script(tokens: &[String], manager: PackageManager) -> Option<(usize, String)> {
|
||||
match manager {
|
||||
PackageManager::Npm => locate_npm_script(tokens),
|
||||
PackageManager::Pnpm => locate_simple_script(tokens),
|
||||
PackageManager::Yarn => locate_simple_script(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
fn locate_npm_script(tokens: &[String]) -> Option<(usize, String)> {
|
||||
if tokens.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if equal_ignore_case(&tokens[1], "run") {
|
||||
let script = tokens.get(2)?.clone();
|
||||
Some((2, script))
|
||||
} else {
|
||||
let script = tokens.get(1)?.clone();
|
||||
Some((1, script))
|
||||
}
|
||||
}
|
||||
|
||||
fn locate_simple_script(tokens: &[String]) -> Option<(usize, String)> {
|
||||
let script = tokens.get(1)?.clone();
|
||||
Some((1, script))
|
||||
}
|
||||
|
||||
fn analyze_direct_formatter(tokens: &[String]) -> Option<InternalAnalysis> {
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let actual = peel_exec_wrappers(tokens);
|
||||
if actual.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base = command_basename(&actual[0]);
|
||||
let args = if actual.len() > 1 { &actual[1..] } else { &[] };
|
||||
|
||||
match base.as_str() {
|
||||
"eslint" => {
|
||||
let has_fix = actual.iter().any(|t| equal_ignore_case(t, "--fix"));
|
||||
if !has_fix {
|
||||
return None;
|
||||
}
|
||||
let is_dry = actual.iter().any(|t| equal_ignore_case(t, "--fix-dry-run") || equal_ignore_case(t, "--dry-run"));
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::EslintFix,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "eslint --fix".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"prettier" => {
|
||||
let has_write = actual.iter().any(|t| equal_ignore_case(t, "--write") || t == "-w");
|
||||
if !has_write {
|
||||
return None;
|
||||
}
|
||||
let is_dry = actual.iter().any(|t| {
|
||||
equal_ignore_case(t, "--check")
|
||||
|| equal_ignore_case(t, "--list-different")
|
||||
|| equal_ignore_case(t, "--dry-run")
|
||||
});
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::PrettierWrite,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "prettier --write".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"black" => {
|
||||
let is_dry = actual.iter().any(|t| equal_ignore_case(t, "--check"));
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::Black,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "black".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"ruff" => {
|
||||
if actual.len() < 2 || !equal_ignore_case(&actual[1], "format") {
|
||||
return None;
|
||||
}
|
||||
let is_dry = args.iter().any(|t| equal_ignore_case(t, "--check"));
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::RuffFormat,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "ruff format".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"isort" => {
|
||||
let is_dry = actual.iter().any(|t| equal_ignore_case(t, "--check-only"));
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::Isort,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "isort".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"gofmt" => {
|
||||
let has_write = actual.iter().any(|t| t == "-w" || t.starts_with("-w="));
|
||||
if !has_write {
|
||||
return None;
|
||||
}
|
||||
let is_dry = actual.iter().any(|t| t == "-d" || t == "-l" || t == "-n");
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::Gofmt,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "gofmt -w".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"rustywind" => {
|
||||
let is_dry = actual.iter().any(|t| equal_ignore_case(t, "--check"));
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::Rustywind,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "rustywind".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"dart" => {
|
||||
if actual.len() < 2 || !equal_ignore_case(&actual[1], "format") {
|
||||
return None;
|
||||
}
|
||||
let is_dry = actual.iter().any(|t| equal_ignore_case(t, "--dry-run"));
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::DartFormat,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "dart format".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
"swiftformat" => {
|
||||
let is_dry = actual.iter().any(|t| equal_ignore_case(t, "--lint") || equal_ignore_case(t, "--dryrun"));
|
||||
Some(InternalAnalysis {
|
||||
key: DryRunGuardKey::SwiftFormat,
|
||||
disposition: if is_dry { DryRunDisposition::DryRun } else { DryRunDisposition::Mutating },
|
||||
display_name: "swiftformat".to_string(),
|
||||
suggested_dry_run: None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn peel_exec_wrappers<'a>(mut tokens: &'a [String]) -> &'a [String] {
|
||||
loop {
|
||||
if tokens.is_empty() {
|
||||
return tokens;
|
||||
}
|
||||
let base = command_basename(&tokens[0]);
|
||||
match base.as_str() {
|
||||
"npx" | "bunx" => {
|
||||
tokens = skip_leading_flags(&tokens[1..]);
|
||||
continue;
|
||||
}
|
||||
"pnpm" => {
|
||||
if tokens.len() >= 2 && matches!(tokens[1].as_str(), "exec" | "dlx") {
|
||||
tokens = skip_leading_flags(&tokens[2..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
"yarn" => {
|
||||
if tokens.len() >= 2 {
|
||||
let second = command_basename(&tokens[1]);
|
||||
if matches!(second.as_str(), "exec" | "dlx") {
|
||||
tokens = skip_leading_flags(&tokens[2..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
break;
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
fn skip_leading_flags<'a>(tokens: &'a [String]) -> &'a [String] {
|
||||
let mut idx = 0usize;
|
||||
while idx < tokens.len() {
|
||||
let token = tokens[idx].as_str();
|
||||
if token == "--" {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if token.starts_with('-') {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
&tokens[idx..]
|
||||
}
|
||||
|
||||
fn slice_contains_flag(args: &[String], flag: &str) -> bool {
|
||||
args.iter().any(|arg| equal_ignore_case(arg, flag))
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub mod config_profile;
|
||||
pub mod config_types;
|
||||
pub mod config_edit;
|
||||
mod conversation_history;
|
||||
mod dry_run_guard;
|
||||
pub mod custom_prompts;
|
||||
mod environment_context;
|
||||
pub mod error;
|
||||
|
||||
Reference in New Issue
Block a user