diff --git a/.claude/hooks/rtk-rewrite.sh b/.claude/hooks/rtk-rewrite.sh index 5c8bad0..ea72857 100755 --- a/.claude/hooks/rtk-rewrite.sh +++ b/.claude/hooks/rtk-rewrite.sh @@ -185,6 +185,10 @@ elif echo "$MATCH_CMD" | grep -qE '^pip[[:space:]]+(list|outdated|install|show)( REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pip /rtk pip /')" elif echo "$MATCH_CMD" | grep -qE '^uv[[:space:]]+pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^uv pip /rtk pip /')" +elif echo "$MATCH_CMD" | grep -qE '^mypy([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^mypy/rtk mypy/')" +elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+mypy([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m mypy/rtk mypy/')" # --- Go tooling --- elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+test([[:space:]]|$)'; then diff --git a/CLAUDE.md b/CLAUDE.md index 6a46ee6..445f738 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,6 +226,7 @@ rtk gain --history | grep proxy | pnpm_cmd.rs | pnpm package manager | Compact dependency trees (70-90% reduction) | | ruff_cmd.rs | Ruff linter/formatter | JSON for check, text for format (80%+ reduction) | | pytest_cmd.rs | Pytest test runner | State machine text parser (90%+ reduction) | +| mypy_cmd.rs | Mypy type checker | Group by file/error code (80% reduction) | | pip_cmd.rs | pip/uv package manager | JSON parsing, auto-detect uv (70-85% reduction) | | go_cmd.rs | Go commands | NDJSON for test, text for build/vet (80-90% reduction) | | golangci_cmd.rs | golangci-lint | JSON parsing, group by rule (85% reduction) | diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index 59e02ca..c929c52 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -175,6 +175,10 @@ elif echo "$MATCH_CMD" | grep -qE '^pip[[:space:]]+(list|outdated|install|show)( REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pip /rtk pip /')" elif echo "$MATCH_CMD" | grep -qE '^uv[[:space:]]+pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^uv pip /rtk pip /')" +elif echo "$MATCH_CMD" | grep -qE '^mypy([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^mypy/rtk mypy/')" +elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+mypy([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m mypy/rtk mypy/')" # --- Go tooling --- elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+test([[:space:]]|$)'; then diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 7ef375c..0f97b95 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -70,6 +70,7 @@ const PATTERNS: &[&str] = &[ r"^kubectl\s+(get|logs)", r"^curl\s+", r"^wget\s+", + r"^(python3?\s+-m\s+)?mypy(\s|$)", ]; const RULES: &[RtkRule] = &[ @@ -225,6 +226,13 @@ const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + rtk_cmd: "rtk mypy", + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). @@ -732,4 +740,30 @@ mod tests { let cmd = "cat <<'EOF'\nhello && world\nEOF"; assert_eq!(split_command_chain(cmd), vec![cmd]); } + + #[test] + fn test_classify_mypy() { + assert_eq!( + classify_command("mypy src/"), + Classification::Supported { + rtk_equivalent: "rtk mypy", + category: "Build", + estimated_savings_pct: 80.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_python_m_mypy() { + assert_eq!( + classify_command("python3 -m mypy --strict"), + Classification::Supported { + rtk_equivalent: "rtk mypy", + category: "Build", + estimated_savings_pct: 80.0, + status: RtkStatus::Existing, + } + ); + } } diff --git a/src/lint_cmd.rs b/src/lint_cmd.rs index 117c87c..eb21198 100644 --- a/src/lint_cmd.rs +++ b/src/lint_cmd.rs @@ -1,3 +1,4 @@ +use crate::mypy_cmd; use crate::ruff_cmd; use crate::tracking; use crate::utils::{package_manager_exec, truncate}; @@ -169,7 +170,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } } "pylint" => filter_pylint_json(&stdout), - "mypy" => filter_mypy_output(&raw), + "mypy" => mypy_cmd::filter_mypy_output(&raw), _ => filter_generic_lint(&raw), }; @@ -402,124 +403,6 @@ fn filter_pylint_json(output: &str) -> String { result.trim().to_string() } -/// Filter mypy text output - parse and group by error code and file -fn filter_mypy_output(output: &str) -> String { - // Regex pattern: path/to/file.py:line: error: message [error-code] - let re = Regex::new(r"^(.+\.py):(\d+): (error|warning|note): (.+?) \[(.+?)\]").unwrap(); - - let mut issues: Vec<(String, String, String, String)> = Vec::new(); // (file, line, level, code) - let mut errors = 0; - let mut warnings = 0; - let mut notes = 0; - - for line in output.lines() { - if let Some(caps) = re.captures(line) { - let file = caps.get(1).map_or("", |m| m.as_str()); - let line_num = caps.get(2).map_or("", |m| m.as_str()); - let level = caps.get(3).map_or("", |m| m.as_str()); - let code = caps.get(5).map_or("", |m| m.as_str()); - - match level { - "error" => errors += 1, - "warning" => warnings += 1, - "note" => notes += 1, - _ => {} - } - - issues.push(( - file.to_string(), - line_num.to_string(), - level.to_string(), - code.to_string(), - )); - } - } - - if issues.is_empty() { - // Check if mypy output contains "Success" or similar - if output.contains("Success") || output.trim().is_empty() { - return "✓ Mypy: No issues found".to_string(); - } - // Fallback to generic output if no regex matches - return format!("Mypy output:\n{}", truncate(output, 500)); - } - - // Count unique files - let unique_files: std::collections::HashSet<_> = issues.iter().map(|(f, _, _, _)| f).collect(); - let total_files = unique_files.len(); - - // Group by error code - let mut by_code: HashMap = HashMap::new(); - for (_, _, _, code) in &issues { - *by_code.entry(code.clone()).or_insert(0) += 1; - } - - // Group by file - let mut by_file: HashMap<&str, usize> = HashMap::new(); - for (file, _, _, _) in &issues { - *by_file.entry(file.as_str()).or_insert(0) += 1; - } - - let mut file_counts: Vec<_> = by_file.iter().collect(); - file_counts.sort_by(|a, b| b.1.cmp(a.1)); - - // Build output - let mut result = String::new(); - result.push_str(&format!( - "Mypy: {} issues in {} files\n", - issues.len(), - total_files - )); - - if errors > 0 || warnings > 0 { - result.push_str(&format!(" {} errors, {} warnings", errors, warnings)); - if notes > 0 { - result.push_str(&format!(", {} notes", notes)); - } - result.push('\n'); - } - - result.push_str("═══════════════════════════════════════\n"); - - // Show top error codes - let mut code_counts: Vec<_> = by_code.iter().collect(); - code_counts.sort_by(|a, b| b.1.cmp(a.1)); - - if !code_counts.is_empty() { - result.push_str("Top error codes:\n"); - for (code, count) in code_counts.iter().take(10) { - result.push_str(&format!(" {} ({}x)\n", code, count)); - } - result.push('\n'); - } - - // Show top files - result.push_str("Top files:\n"); - for (file, count) in file_counts.iter().take(10) { - let short_path = compact_path(file); - result.push_str(&format!(" {} ({} issues)\n", short_path, count)); - - // Show top 3 error codes in this file - let mut file_codes: HashMap = HashMap::new(); - for (_f, _, _, code) in issues.iter().filter(|(f, _, _, _)| f == *file) { - *file_codes.entry(code.clone()).or_insert(0) += 1; - } - - let mut file_code_counts: Vec<_> = file_codes.iter().collect(); - file_code_counts.sort_by(|a, b| b.1.cmp(a.1)); - - for (code, count) in file_code_counts.iter().take(3) { - result.push_str(&format!(" {} ({})\n", code, count)); - } - } - - if file_counts.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); - } - - result.trim().to_string() -} - /// Filter generic linter output (fallback for non-ESLint linters) fn filter_generic_lint(output: &str) -> String { let mut warnings = 0; @@ -693,33 +576,6 @@ mod tests { assert!(result.contains("utils.py")); } - #[test] - fn test_filter_mypy_no_issues() { - let output = "Success: no issues found in 5 source files"; - let result = filter_mypy_output(output); - assert!(result.contains("✓ Mypy")); - assert!(result.contains("No issues found")); - } - - #[test] - fn test_filter_mypy_with_errors() { - let output = r#"src/main.py:10: error: Incompatible return value type [return-value] -src/main.py:15: error: Argument 1 has incompatible type "str"; expected "int" [arg-type] -src/utils.py:20: error: Name "foo" is not defined [name-defined] -src/utils.py:25: warning: Unused "type: ignore" comment [unused-ignore] -Found 4 errors in 2 files (checked 5 source files)"#; - - let result = filter_mypy_output(output); - assert!(result.contains("4 issues")); - assert!(result.contains("2 files")); - assert!(result.contains("3 errors, 1 warnings")); - assert!(result.contains("return-value")); - assert!(result.contains("arg-type")); - assert!(result.contains("name-defined")); - assert!(result.contains("main.py")); - assert!(result.contains("utils.py")); - } - #[test] fn test_is_python_linter() { assert!(is_python_linter("ruff")); diff --git a/src/main.rs b/src/main.rs index 0e58173..0069cb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod lint_cmd; mod local_llm; mod log_cmd; mod ls; +mod mypy_cmd; mod next_cmd; mod npm_cmd; mod parser; @@ -498,6 +499,13 @@ enum Commands { args: Vec, }, + /// Mypy type checker with grouped error output + Mypy { + /// Mypy arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Pip package manager with compact output (auto-detects uv) Pip { /// Pip arguments (e.g., list, outdated, install) @@ -1383,6 +1391,10 @@ fn main() -> Result<()> { pytest_cmd::run(&args, cli.verbose)?; } + Commands::Mypy { args } => { + mypy_cmd::run(&args, cli.verbose)?; + } + Commands::Pip { args } => { pip_cmd::run(&args, cli.verbose)?; } diff --git a/src/mypy_cmd.rs b/src/mypy_cmd.rs new file mode 100644 index 0000000..01a32cf --- /dev/null +++ b/src/mypy_cmd.rs @@ -0,0 +1,389 @@ +use crate::tracking; +use crate::utils::{strip_ansi, truncate}; +use anyhow::{Context, Result}; +use regex::Regex; +use std::collections::HashMap; +use std::process::Command; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = if which_command("mypy").is_some() { + Command::new("mypy") + } else { + let mut c = Command::new("python3"); + c.arg("-m").arg("mypy"); + c + }; + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: mypy {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run mypy. Is it installed? Try: pip install mypy")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + let clean = strip_ansi(&raw); + + let filtered = filter_mypy_output(&clean); + + println!("{}", filtered); + + timer.track( + &format!("mypy {}", args.join(" ")), + &format!("rtk mypy {}", args.join(" ")), + &raw, + &filtered, + ); + + std::process::exit(output.status.code().unwrap_or(1)); +} + +fn which_command(cmd: &str) -> Option { + Command::new("which") + .arg(cmd) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +struct MypyError { + file: String, + line: usize, + code: String, + message: String, + context_lines: Vec, +} + +pub fn filter_mypy_output(output: &str) -> String { + lazy_static::lazy_static! { + // file.py:12: error: Message [error-code] + // file.py:12:5: error: Message [error-code] + static ref MYPY_DIAG: Regex = Regex::new( + r"^(.+?):(\d+)(?::\d+)?: (error|warning|note): (.+?)(?:\s+\[(.+)\])?$" + ).unwrap(); + } + + let lines: Vec<&str> = output.lines().collect(); + let mut errors: Vec = Vec::new(); + let mut fileless_lines: Vec = Vec::new(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + + // Skip mypy's own summary line + if line.starts_with("Found ") && line.contains(" error") { + i += 1; + continue; + } + // Skip "Success: no issues found" + if line.starts_with("Success:") { + i += 1; + continue; + } + + if let Some(caps) = MYPY_DIAG.captures(line) { + let severity = &caps[3]; + let file = caps[1].to_string(); + let line_num: usize = caps[2].parse().unwrap_or(0); + let message = caps[4].to_string(); + let code = caps + .get(5) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + + if severity == "note" { + // Attach note to preceding error if same file and line + if let Some(last) = errors.last_mut() { + if last.file == file { + last.context_lines.push(message); + i += 1; + continue; + } + } + // Standalone note with no parent -- display as fileless + fileless_lines.push(line.to_string()); + i += 1; + continue; + } + + let mut err = MypyError { + file, + line: line_num, + code, + message, + context_lines: Vec::new(), + }; + + // Capture continuation note lines + i += 1; + while i < lines.len() { + if let Some(next_caps) = MYPY_DIAG.captures(lines[i]) { + if &next_caps[3] == "note" && next_caps[1] == err.file { + let note_msg = next_caps[4].to_string(); + err.context_lines.push(note_msg); + i += 1; + continue; + } + } + break; + } + + errors.push(err); + } else if line.contains("error:") && !line.trim().is_empty() { + // File-less error (config errors, import errors) + fileless_lines.push(line.to_string()); + i += 1; + } else { + i += 1; + } + } + + // No errors at all + if errors.is_empty() && fileless_lines.is_empty() { + if output.contains("Success: no issues found") || output.contains("no issues found") { + return "mypy: No issues found".to_string(); + } + return "mypy: No issues found".to_string(); + } + + // Group by file + let mut by_file: HashMap> = HashMap::new(); + for err in &errors { + by_file.entry(err.file.clone()).or_default().push(err); + } + + // Count by error code + let mut by_code: HashMap = HashMap::new(); + for err in &errors { + if !err.code.is_empty() { + *by_code.entry(err.code.clone()).or_insert(0) += 1; + } + } + + let mut result = String::new(); + + // File-less errors first + for line in &fileless_lines { + result.push_str(line); + result.push('\n'); + } + if !fileless_lines.is_empty() && !errors.is_empty() { + result.push('\n'); + } + + if !errors.is_empty() { + result.push_str(&format!( + "mypy: {} errors in {} files\n", + errors.len(), + by_file.len() + )); + result.push_str("═══════════════════════════════════════\n"); + + // Top error codes summary (only when 2+ distinct codes) + let mut code_counts: Vec<_> = by_code.iter().collect(); + code_counts.sort_by(|a, b| b.1.cmp(a.1)); + + if code_counts.len() > 1 { + let codes_str: Vec = code_counts + .iter() + .take(5) + .map(|(code, count)| format!("{} ({}x)", code, count)) + .collect(); + result.push_str(&format!("Top codes: {}\n\n", codes_str.join(", "))); + } + + // Files sorted by error count (most errors first) + let mut files_sorted: Vec<_> = by_file.iter().collect(); + files_sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + + for (file, file_errors) in &files_sorted { + result.push_str(&format!("{} ({} errors)\n", file, file_errors.len())); + + for err in *file_errors { + if err.code.is_empty() { + result.push_str(&format!( + " L{}: {}\n", + err.line, + truncate(&err.message, 120) + )); + } else { + result.push_str(&format!( + " L{}: [{}] {}\n", + err.line, + err.code, + truncate(&err.message, 120) + )); + } + for ctx in &err.context_lines { + result.push_str(&format!(" {}\n", truncate(ctx, 120))); + } + } + result.push('\n'); + } + } + + result.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_mypy_errors_grouped_by_file() { + let output = "\ +src/server/auth.py:12: error: Incompatible return value type (got \"str\", expected \"int\") [return-value] +src/server/auth.py:15: error: Argument 1 has incompatible type \"int\"; expected \"str\" [arg-type] +src/models/user.py:8: error: Name \"foo\" is not defined [name-defined] +src/models/user.py:10: error: Incompatible types in assignment [assignment] +src/models/user.py:20: error: Missing return statement [return] +Found 5 errors in 2 files (checked 10 source files) +"; + let result = filter_mypy_output(output); + assert!(result.contains("mypy: 5 errors in 2 files")); + // user.py has 3 errors, auth.py has 2 -- user.py should come first + let user_pos = result.find("user.py").unwrap(); + let auth_pos = result.find("auth.py").unwrap(); + assert!( + user_pos < auth_pos, + "user.py (3 errors) should appear before auth.py (2 errors)" + ); + assert!(result.contains("user.py (3 errors)")); + assert!(result.contains("auth.py (2 errors)")); + } + + #[test] + fn test_filter_mypy_with_column_numbers() { + let output = "\ +src/api.py:10:5: error: Incompatible return value type [return-value] +"; + let result = filter_mypy_output(output); + assert!(result.contains("L10:")); + assert!(result.contains("[return-value]")); + assert!(result.contains("Incompatible return value type")); + } + + #[test] + fn test_filter_mypy_top_codes_summary() { + let output = "\ +a.py:1: error: Error one [return-value] +a.py:2: error: Error two [return-value] +a.py:3: error: Error three [return-value] +b.py:1: error: Error four [name-defined] +c.py:1: error: Error five [arg-type] +Found 5 errors in 3 files +"; + let result = filter_mypy_output(output); + assert!(result.contains("Top codes:")); + assert!(result.contains("return-value (3x)")); + assert!(result.contains("name-defined (1x)")); + assert!(result.contains("arg-type (1x)")); + } + + #[test] + fn test_filter_mypy_single_code_no_summary() { + let output = "\ +a.py:1: error: Error one [return-value] +a.py:2: error: Error two [return-value] +b.py:1: error: Error three [return-value] +Found 3 errors in 2 files +"; + let result = filter_mypy_output(output); + assert!( + !result.contains("Top codes:"), + "Top codes should not appear with only one distinct code" + ); + } + + #[test] + fn test_filter_mypy_every_error_shown() { + let output = "\ +src/api.py:10: error: Type \"str\" not assignable to \"int\" [assignment] +src/api.py:20: error: Missing return statement [return] +src/api.py:30: error: Name \"bar\" is not defined [name-defined] +"; + let result = filter_mypy_output(output); + assert!(result.contains("Type \"str\" not assignable to \"int\"")); + assert!(result.contains("Missing return statement")); + assert!(result.contains("Name \"bar\" is not defined")); + assert!(result.contains("L10:")); + assert!(result.contains("L20:")); + assert!(result.contains("L30:")); + } + + #[test] + fn test_filter_mypy_note_continuation() { + let output = "\ +src/app.py:10: error: Incompatible types in assignment [assignment] +src/app.py:10: note: Expected type \"int\" +src/app.py:10: note: Got type \"str\" +src/app.py:20: error: Missing return statement [return] +"; + let result = filter_mypy_output(output); + assert!(result.contains("Incompatible types in assignment")); + assert!(result.contains("Expected type \"int\"")); + assert!(result.contains("Got type \"str\"")); + assert!(result.contains("L10:")); + assert!(result.contains("L20:")); + } + + #[test] + fn test_filter_mypy_fileless_errors() { + let output = "\ +mypy: error: No module named 'nonexistent' +src/api.py:10: error: Name \"foo\" is not defined [name-defined] +Found 1 error in 1 file +"; + let result = filter_mypy_output(output); + // File-less error should appear verbatim before grouped output + assert!(result.contains("mypy: error: No module named 'nonexistent'")); + assert!(result.contains("api.py (1 error")); + let fileless_pos = result.find("No module named").unwrap(); + let grouped_pos = result.find("api.py").unwrap(); + assert!( + fileless_pos < grouped_pos, + "File-less errors should appear before grouped file errors" + ); + } + + #[test] + fn test_filter_mypy_no_errors() { + let output = "Success: no issues found in 5 source files\n"; + let result = filter_mypy_output(output); + assert_eq!(result, "mypy: No issues found"); + } + + #[test] + fn test_filter_mypy_no_file_limit() { + let mut output = String::new(); + for i in 1..=15 { + output.push_str(&format!( + "src/file{}.py:{}: error: Error in file {}. [assignment]\n", + i, i, i + )); + } + output.push_str("Found 15 errors in 15 files\n"); + let result = filter_mypy_output(&output); + assert!(result.contains("15 errors in 15 files")); + for i in 1..=15 { + assert!( + result.contains(&format!("file{}.py", i)), + "file{}.py missing from output", + i + ); + } + } +}