diff --git a/crates/rskim/src/analytics/mod.rs b/crates/rskim/src/analytics/mod.rs index 5bc3127..45e1abb 100644 --- a/crates/rskim/src/analytics/mod.rs +++ b/crates/rskim/src/analytics/mod.rs @@ -39,6 +39,7 @@ pub(crate) enum CommandType { Test, Build, Git, + Lint, } impl CommandType { @@ -48,6 +49,7 @@ impl CommandType { CommandType::Test => "test", CommandType::Build => "build", CommandType::Git => "git", + CommandType::Lint => "lint", } } } diff --git a/crates/rskim/src/cmd/lint/eslint.rs b/crates/rskim/src/cmd/lint/eslint.rs new file mode 100644 index 0000000..2a1717b --- /dev/null +++ b/crates/rskim/src/cmd/lint/eslint.rs @@ -0,0 +1,278 @@ +//! ESLint parser with three-tier degradation (#104). +//! +//! Executes `eslint` and parses the output into a structured `LintResult`. +//! +//! Three tiers: +//! - **Tier 1 (Full)**: JSON array parsing (`--format json`) +//! - **Tier 2 (Degraded)**: Regex on default formatter output +//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::{combine_stdout_stderr, group_issues, LinterConfig}; + +const CONFIG: LinterConfig<'static> = LinterConfig { + program: "eslint", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install eslint via npm: npm install -g eslint", +}; + +// Static regex patterns compiled once via LazyLock. +static RE_ESLINT_LINE: LazyLock = LazyLock::new(|| { + // Matches: " 12:7 warning 'x' is defined but never used no-unused-vars" + Regex::new(r"^\s+(\d+):\d+\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$").unwrap() +}); + +static RE_ESLINT_FILE: LazyLock = + LazyLock::new(|| Regex::new(r"^(/[^\s]+|[A-Z]:\\[^\s]+)$").unwrap()); + +/// Run `skim lint eslint [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_linter( + CONFIG, + args, + show_stats, + json_output, + prepare_args, + parse_impl, + ) +} + +/// Inject `--format json` if not already present. +fn prepare_args(cmd_args: &mut Vec) { + if !user_has_flag(cmd_args, &["--format", "-f"]) { + cmd_args.insert(0, "json".to_string()); + cmd_args.insert(0, "--format".to_string()); + } +} + +/// Three-tier parse function for eslint output. +fn parse_impl(output: &CommandOutput) -> ParseResult { + if let Some(result) = try_parse_json(&output.stdout) { + return ParseResult::Full(result); + } + + let combined = combine_stdout_stderr(output); + + if let Some(result) = try_parse_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + ParseResult::Passthrough(combined.into_owned()) +} + +// ============================================================================ +// Tier 1: JSON parsing +// ============================================================================ + +/// Parse eslint JSON output format. +/// +/// ESLint `--format json` produces an array of file results: +/// ```json +/// [{"filePath": "...", "messages": [{"ruleId": "...", "severity": 1, "message": "...", "line": 12}]}] +/// ``` +fn try_parse_json(stdout: &str) -> Option { + let arr: Vec = serde_json::from_str(stdout.trim()).ok()?; + + let mut issues: Vec = Vec::new(); + + for file_entry in &arr { + let Some(file_path) = file_entry.get("filePath").and_then(|v| v.as_str()) else { + continue; + }; + let Some(messages) = file_entry.get("messages").and_then(|v| v.as_array()) else { + continue; + }; + + for msg in messages { + let Some(severity_num) = msg.get("severity").and_then(|v| v.as_u64()) else { + continue; + }; + let severity = match severity_num { + 2 => LintSeverity::Error, + 1 => LintSeverity::Warning, + _ => LintSeverity::Info, + }; + let rule_id = msg + .get("ruleId") + .and_then(|v| v.as_str()) + .unwrap_or("(unknown)"); + let Some(message) = msg.get("message").and_then(|v| v.as_str()) else { + continue; + }; + let Some(line) = msg.get("line").and_then(|v| v.as_u64()) else { + continue; + }; + + issues.push(LintIssue { + file: file_path.to_string(), + line: u32::try_from(line).unwrap_or(u32::MAX), + rule: rule_id.to_string(), + message: message.to_string(), + severity, + }); + } + } + + Some(group_issues("eslint", issues)) +} + +// ============================================================================ +// Tier 2: regex fallback +// ============================================================================ + +/// Parse eslint default formatter output via regex. +/// +/// Format: +/// ```text +/// /path/to/file.ts +/// 12:7 warning 'x' is defined but never used no-unused-vars +/// ``` +fn try_parse_regex(text: &str) -> Option { + let mut issues: Vec = Vec::new(); + let mut current_file = String::new(); + + for line in text.lines() { + // Try to match a file path line + if RE_ESLINT_FILE.is_match(line.trim()) { + current_file = line.trim().to_string(); + continue; + } + + // Try to match an issue line + if let Some(caps) = RE_ESLINT_LINE.captures(line) { + let line_num: u32 = caps[1].parse().unwrap_or(0); + let severity = match &caps[2] { + "error" => LintSeverity::Error, + "warning" => LintSeverity::Warning, + _ => LintSeverity::Info, + }; + let message = caps[3].to_string(); + let rule = caps[4].to_string(); + + issues.push(LintIssue { + file: current_file.clone(), + line: line_num, + rule, + message, + severity, + }); + } + } + + if issues.is_empty() { + return None; + } + + Some(group_issues("eslint", issues)) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn load_fixture(name: &str) -> String { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/lint"); + path.push(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + #[test] + fn test_tier1_eslint_pass() { + let input = load_fixture("eslint_pass.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 0); + assert_eq!(result.warnings, 0); + assert!(result.as_ref().contains("LINT OK")); + } + + #[test] + fn test_tier1_eslint_fail() { + let input = load_fixture("eslint_fail.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 2); + assert_eq!(result.warnings, 3); + assert!(result.groups.len() >= 2, "Expected at least 2 rule groups"); + } + + #[test] + fn test_tier2_eslint_regex() { + let input = load_fixture("eslint_text.txt"); + let result = try_parse_regex(&input); + assert!(result.is_some(), "Expected Tier 2 regex parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 2); + assert_eq!(result.warnings, 2); + } + + #[test] + fn test_parse_impl_json_produces_full() { + let input = load_fixture("eslint_fail.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_full(), + "Expected Full parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_text_produces_degraded() { + let input = load_fixture("eslint_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_degraded(), + "Expected Degraded parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "completely unparseable output\nno json, no regex match".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_passthrough(), + "Expected Passthrough, got {}", + result.tier_name() + ); + } +} diff --git a/crates/rskim/src/cmd/lint/golangci.rs b/crates/rskim/src/cmd/lint/golangci.rs new file mode 100644 index 0000000..f1f5666 --- /dev/null +++ b/crates/rskim/src/cmd/lint/golangci.rs @@ -0,0 +1,264 @@ +//! golangci-lint parser with three-tier degradation (#104). +//! +//! Executes `golangci-lint run` and parses the output into a structured `LintResult`. +//! +//! Three tiers: +//! - **Tier 1 (Full)**: JSON object parsing (`--out-format json`) +//! - **Tier 2 (Degraded)**: Regex on default text output +//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::{combine_stdout_stderr, group_issues, LinterConfig}; + +const CONFIG: LinterConfig<'static> = LinterConfig { + program: "golangci-lint", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install golangci-lint: https://golangci-lint.run/welcome/install/", +}; + +// Static regex pattern compiled once via LazyLock. +static RE_GOLANGCI_LINE: LazyLock = + LazyLock::new(|| Regex::new(r"^(.+):(\d+)(?::\d+)?:\s+(.+)\s+\((\S+)\)$").unwrap()); + +/// Run `skim lint golangci [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_linter( + CONFIG, + args, + show_stats, + json_output, + prepare_args, + parse_impl, + ) +} + +/// Ensure "run" subcommand is present and inject `--out-format json`. +fn prepare_args(cmd_args: &mut Vec) { + // Ensure "run" subcommand is present if args don't start with it + if cmd_args.first().is_none_or(|a| a != "run") { + cmd_args.insert(0, "run".to_string()); + } + + // Inject --out-format json if not already present + if !user_has_flag(cmd_args, &["--out-format"]) { + cmd_args.push("--out-format".to_string()); + cmd_args.push("json".to_string()); + } +} + +/// Three-tier parse function for golangci-lint output. +fn parse_impl(output: &CommandOutput) -> ParseResult { + if let Some(result) = try_parse_json(&output.stdout) { + return ParseResult::Full(result); + } + + let combined = combine_stdout_stderr(output); + + if let Some(result) = try_parse_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + ParseResult::Passthrough(combined.into_owned()) +} + +// ============================================================================ +// Tier 1: JSON parsing +// ============================================================================ + +/// Parse golangci-lint JSON output format. +/// +/// golangci-lint `--out-format json` produces: +/// ```json +/// {"Issues": [{"FromLinter": "govet", "Text": "...", "Pos": {"Filename": "...", "Line": 42}}]} +/// ``` +fn try_parse_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?; + let obj = value.as_object()?; + + // Must have "Issues" key (golangci-lint JSON always has this) + let issues_val = obj.get("Issues")?; + + // Issues can be null (no issues) or an array + let issues_arr = match issues_val { + serde_json::Value::Null => &[] as &[serde_json::Value], + serde_json::Value::Array(arr) => arr.as_slice(), + _ => return None, + }; + + let mut issues: Vec = Vec::new(); + + for entry in issues_arr { + let linter = entry + .get("FromLinter") + .and_then(|v| v.as_str()) + .unwrap_or("(unknown)"); + let text = entry.get("Text").and_then(|v| v.as_str()).unwrap_or(""); + let Some(pos) = entry.get("Pos") else { + continue; + }; + let Some(filename) = pos.get("Filename").and_then(|v| v.as_str()) else { + continue; + }; + let Some(line) = pos.get("Line").and_then(|v| v.as_u64()) else { + continue; + }; + + let severity_str = entry.get("Severity").and_then(|v| v.as_str()).unwrap_or(""); + let severity = match severity_str { + "error" => LintSeverity::Error, + // golangci-lint defaults to warning when Severity is empty/absent/unknown + _ => LintSeverity::Warning, + }; + + issues.push(LintIssue { + file: filename.to_string(), + line: u32::try_from(line).unwrap_or(u32::MAX), + rule: linter.to_string(), + message: text.to_string(), + severity, + }); + } + + Some(group_issues("golangci", issues)) +} + +// ============================================================================ +// Tier 2: regex fallback +// ============================================================================ + +/// Parse golangci-lint default text output via regex. +/// +/// Format: `file:line:col: message (linter)` +/// +/// **Known limitation:** The golangci-lint text format does not include severity +/// information, so all Tier 2 issues default to `Warning`. Accurate severity is +/// only available via Tier 1 JSON parsing (the `Severity` field in JSON output). +fn try_parse_regex(text: &str) -> Option { + let mut issues: Vec = Vec::new(); + + for line in text.lines() { + if let Some(caps) = RE_GOLANGCI_LINE.captures(line) { + let file = caps[1].to_string(); + let line_num: u32 = caps[2].parse().unwrap_or(0); + let message = caps[3].to_string(); + let linter = caps[4].to_string(); + + issues.push(LintIssue { + file, + line: line_num, + rule: linter, + message, + // Text format lacks severity; default to Warning (see doc comment above). + severity: LintSeverity::Warning, + }); + } + } + + if issues.is_empty() { + return None; + } + + Some(group_issues("golangci", issues)) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn load_fixture(name: &str) -> String { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/lint"); + path.push(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + #[test] + fn test_tier1_golangci_fail() { + let input = load_fixture("golangci_fail.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed"); + let result = result.unwrap(); + // 4 issues total: 1 error (staticcheck), 3 warnings (govet x2 + errcheck) + assert_eq!(result.errors, 1); + assert_eq!(result.warnings, 3); + } + + #[test] + fn test_tier1_golangci_null_issues() { + let input = r#"{"Issues": null}"#; + let result = try_parse_json(input); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result.errors, 0); + assert_eq!(result.warnings, 0); + assert!(result.as_ref().contains("LINT OK")); + } + + #[test] + fn test_tier2_golangci_regex() { + let input = load_fixture("golangci_text.txt"); + let result = try_parse_regex(&input); + assert!(result.is_some(), "Expected Tier 2 regex parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.warnings, 4); + } + + #[test] + fn test_parse_impl_json_produces_full() { + let input = load_fixture("golangci_fail.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_full()); + } + + #[test] + fn test_parse_impl_text_produces_degraded() { + let input = load_fixture("golangci_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_degraded(), + "Expected Degraded parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "random garbage".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_passthrough()); + } +} diff --git a/crates/rskim/src/cmd/lint/mod.rs b/crates/rskim/src/cmd/lint/mod.rs new file mode 100644 index 0000000..4ba8900 --- /dev/null +++ b/crates/rskim/src/cmd/lint/mod.rs @@ -0,0 +1,303 @@ +//! Lint subcommand dispatcher (#104) +//! +//! Routes `skim lint [args...]` to the appropriate linter parser. +//! Currently supported linters: `eslint`, `ruff`, `mypy`, `golangci`. + +pub(crate) mod eslint; +pub(crate) mod golangci; +pub(crate) mod mypy; +pub(crate) mod ruff; + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::io::IsTerminal; +use std::process::ExitCode; + +use super::{ + extract_show_stats, run_parsed_command_with_mode, OutputFormat, ParsedCommandConfig, +}; +use crate::output::canonical::{LintGroup, LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +/// Known linters that `skim lint` can dispatch to. +const KNOWN_LINTERS: &[&str] = &["eslint", "ruff", "mypy", "golangci"]; + +/// Entry point for `skim lint [args...]`. +/// +/// If no linter is specified or `--help` / `-h` is passed, prints usage +/// and exits. Otherwise dispatches to the linter-specific handler. +pub(crate) fn run(args: &[String]) -> anyhow::Result { + if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) { + print_help(); + return Ok(ExitCode::SUCCESS); + } + + let (filtered_args, show_stats) = extract_show_stats(args); + + // Extract --json flag + let (filtered_args, json_output) = extract_json_flag(&filtered_args); + + let Some((linter_name, linter_args)) = filtered_args.split_first() else { + print_help(); + return Ok(ExitCode::SUCCESS); + }; + + let linter = linter_name.as_str(); + + match linter { + "eslint" => eslint::run(linter_args, show_stats, json_output), + "ruff" => ruff::run(linter_args, show_stats, json_output), + "mypy" => mypy::run(linter_args, show_stats, json_output), + "golangci" => golangci::run(linter_args, show_stats, json_output), + _ => { + eprintln!( + "skim lint: unknown linter '{linter}'\n\ + Available linters: {}\n\ + Run 'skim lint --help' for usage information", + KNOWN_LINTERS.join(", ") + ); + Ok(ExitCode::FAILURE) + } + } +} + +fn print_help() { + println!("skim lint [args...]"); + println!(); + println!(" Run linters and parse the output for AI context windows."); + println!(); + println!("Available linters:"); + for linter in KNOWN_LINTERS { + println!(" {linter}"); + } + println!(); + println!("Flags:"); + println!(" --json Emit structured JSON output"); + println!(" --show-stats Show token statistics"); + println!(); + println!("Examples:"); + println!(" skim lint eslint . Run eslint"); + println!(" skim lint ruff check . Run ruff check"); + println!(" skim lint mypy src/ Run mypy"); + println!(" skim lint golangci run ./... Run golangci-lint"); + println!(" eslint . 2>&1 | skim lint eslint Pipe eslint output"); +} + +/// Extract `--json` flag from args, returning (filtered_args, json_output). +fn extract_json_flag(args: &[String]) -> (Vec, bool) { + let json_output = args.iter().any(|a| a == "--json"); + let filtered: Vec = args + .iter() + .filter(|a| a.as_str() != "--json") + .cloned() + .collect(); + (filtered, json_output) +} + +// ============================================================================ +// Shared linter execution helper +// ============================================================================ + +/// Static configuration for a linter binary. +/// +/// Each linter module exposes a `CONFIG` constant with this type. +pub(crate) struct LinterConfig<'a> { + /// Binary name of the linter (e.g., "eslint", "ruff"). + pub program: &'a str, + /// Environment variable overrides for the child process. + pub env_overrides: &'a [(&'a str, &'a str)], + /// Hint printed when the linter binary is not found. + pub install_hint: &'a str, +} + +/// Execute a linter, parse its output, and emit the result. +/// +/// This is the single implementation shared by all lint parsers, handling both +/// text and JSON output modes. It eliminates per-linter `run()` boilerplate by +/// delegating to [`super::run_parsed_command_with_mode`]. +/// +/// - `config`: static linter metadata (program name, env vars, install hint) +/// - `args`: raw user args (before prepare_args) +/// - `show_stats`: whether to report token statistics +/// - `json_output`: whether to emit JSON instead of text +/// - `prepare_args`: closure to inject linter-specific flags (e.g., `--format json`) +/// - `parse_fn`: linter-specific three-tier parse function +pub(crate) fn run_linter( + config: LinterConfig<'_>, + args: &[String], + show_stats: bool, + json_output: bool, + prepare_args: impl FnOnce(&mut Vec), + parse_fn: impl FnOnce(&CommandOutput) -> ParseResult, +) -> anyhow::Result { + let mut cmd_args = args.to_vec(); + prepare_args(&mut cmd_args); + + let use_stdin = !std::io::stdin().is_terminal() && args.is_empty(); + let output_format = if json_output { + OutputFormat::Json + } else { + OutputFormat::Text + }; + + run_parsed_command_with_mode( + ParsedCommandConfig { + program: config.program, + args: &cmd_args, + env_overrides: config.env_overrides, + install_hint: config.install_hint, + use_stdin, + show_stats, + command_type: crate::analytics::CommandType::Lint, + output_format, + }, + |output, _args| parse_fn(output), + ) +} + +/// Combine stdout and stderr into a single string for regex fallback parsing. +/// +/// Returns a borrowed reference to stdout when stderr is empty to avoid an +/// unnecessary allocation. +pub(crate) fn combine_stdout_stderr(output: &CommandOutput) -> Cow<'_, str> { + if output.stderr.is_empty() { + Cow::Borrowed(&output.stdout) + } else { + Cow::Owned(format!("{}\n{}", output.stdout, output.stderr)) + } +} + +/// Group individual lint issues by rule into a `LintResult`. +/// +/// Uses `BTreeMap` for deterministic ordering of rule groups. +pub(crate) fn group_issues(tool: &str, issues: Vec) -> LintResult { + let mut groups: BTreeMap = BTreeMap::new(); + let mut errors = 0usize; + let mut warnings = 0usize; + for issue in issues { + match issue.severity { + LintSeverity::Error => errors += 1, + LintSeverity::Warning => warnings += 1, + LintSeverity::Info => {} + } + let location = format!("{}:{}", issue.file, issue.line); + let group = groups + .entry(issue.rule.clone()) + .or_insert_with(|| LintGroup { + rule: issue.rule, + count: 0, + severity: issue.severity, + locations: Vec::new(), + }); + group.count += 1; + group.locations.push(location); + } + LintResult::new( + tool.to_string(), + errors, + warnings, + groups.into_values().collect(), + ) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::canonical::{LintIssue, LintSeverity}; + + #[test] + fn test_group_issues_info_severity_not_counted() { + let issues = vec![ + LintIssue { + file: "a.ts".to_string(), + line: 1, + rule: "info-rule".to_string(), + message: "informational".to_string(), + severity: LintSeverity::Info, + }, + LintIssue { + file: "a.ts".to_string(), + line: 2, + rule: "err-rule".to_string(), + message: "real error".to_string(), + severity: LintSeverity::Error, + }, + ]; + let result = group_issues("test", issues); + assert_eq!(result.errors, 1); + assert_eq!(result.warnings, 0); + // Info issue is grouped but not counted as error or warning + assert_eq!(result.groups.len(), 2); + } + + #[test] + fn test_group_issues_empty() { + let result = group_issues("test", vec![]); + assert_eq!(result.errors, 0); + assert_eq!(result.warnings, 0); + assert!(result.groups.is_empty()); + assert!(result.as_ref().contains("LINT OK")); + } + + #[test] + fn test_combine_stdout_stderr_empty_stderr() { + let output = CommandOutput { + stdout: "hello world".to_string(), + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let combined = combine_stdout_stderr(&output); + assert_eq!(&*combined, "hello world"); + // When stderr is empty, should borrow (Cow::Borrowed) + assert!(matches!(combined, Cow::Borrowed(_))); + } + + #[test] + fn test_combine_stdout_stderr_with_stderr() { + let output = CommandOutput { + stdout: "out".to_string(), + stderr: "err".to_string(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let combined = combine_stdout_stderr(&output); + assert_eq!(&*combined, "out\nerr"); + // When stderr is non-empty, should own (Cow::Owned) + assert!(matches!(combined, Cow::Owned(_))); + } + + #[test] + fn test_combine_stdout_stderr_both_empty() { + let output = CommandOutput { + stdout: String::new(), + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let combined = combine_stdout_stderr(&output); + assert_eq!(&*combined, ""); + assert!(matches!(combined, Cow::Borrowed(_))); + } + + #[test] + fn test_extract_json_flag_present() { + let args = vec!["--json".to_string(), "eslint".to_string(), ".".to_string()]; + let (filtered, json_output) = extract_json_flag(&args); + assert!(json_output); + assert_eq!(filtered, vec!["eslint".to_string(), ".".to_string()]); + } + + #[test] + fn test_extract_json_flag_absent() { + let args = vec!["eslint".to_string(), ".".to_string()]; + let (filtered, json_output) = extract_json_flag(&args); + assert!(!json_output); + assert_eq!(filtered, vec!["eslint".to_string(), ".".to_string()]); + } +} diff --git a/crates/rskim/src/cmd/lint/mypy.rs b/crates/rskim/src/cmd/lint/mypy.rs new file mode 100644 index 0000000..39cf18c --- /dev/null +++ b/crates/rskim/src/cmd/lint/mypy.rs @@ -0,0 +1,257 @@ +//! mypy parser with three-tier degradation (#104). +//! +//! Executes `mypy` and parses the output into a structured `LintResult`. +//! +//! Three tiers: +//! - **Tier 1 (Full)**: NDJSON parsing (`--output json`) +//! - **Tier 2 (Degraded)**: Regex on default text output +//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::{combine_stdout_stderr, group_issues, LinterConfig}; + +const CONFIG: LinterConfig<'static> = LinterConfig { + program: "mypy", + env_overrides: &[("NO_COLOR", "1"), ("MYPY_FORCE_COLOR", "0")], + install_hint: "Install mypy: pip install mypy", +}; + +// Static regex pattern compiled once via LazyLock. +static RE_MYPY_LINE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(.+):(\d+):\s+(error|warning|note):\s+(.+?)(?:\s+\[(\S+)\])?$").unwrap() +}); + +/// Run `skim lint mypy [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_linter( + CONFIG, + args, + show_stats, + json_output, + prepare_args, + parse_impl, + ) +} + +/// Inject `--output json` if not already present. +fn prepare_args(cmd_args: &mut Vec) { + if !user_has_flag(cmd_args, &["--output"]) { + cmd_args.insert(0, "json".to_string()); + cmd_args.insert(0, "--output".to_string()); + } +} + +/// Three-tier parse function for mypy output. +fn parse_impl(output: &CommandOutput) -> ParseResult { + if let Some(result) = try_parse_json(&output.stdout) { + return ParseResult::Full(result); + } + + let combined = combine_stdout_stderr(output); + + if let Some(result) = try_parse_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + ParseResult::Passthrough(combined.into_owned()) +} + +// ============================================================================ +// Tier 1: NDJSON parsing +// ============================================================================ + +/// Parse mypy NDJSON output format. +/// +/// mypy `--output json` produces one JSON object per line (NDJSON): +/// ```json +/// {"file": "...", "line": 10, "column": 5, "message": "...", "code": "...", "severity": "error"} +/// ``` +fn try_parse_json(stdout: &str) -> Option { + let mut issues: Vec = Vec::new(); + let mut any_parsed = false; + + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + + // Must have at least "file" and "line" fields to be a valid mypy JSON entry + let Some(file) = value.get("file").and_then(|v| v.as_str()) else { + continue; + }; + let Some(line_num) = value.get("line").and_then(|v| v.as_u64()) else { + continue; + }; + + any_parsed = true; + + let severity_str = value + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("error"); + let severity = match severity_str { + "error" => LintSeverity::Error, + "warning" => LintSeverity::Warning, + "note" => LintSeverity::Info, + _ => LintSeverity::Error, + }; + + let code = value + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("(unknown)"); + let message = value.get("message").and_then(|v| v.as_str()).unwrap_or(""); + + issues.push(LintIssue { + file: file.to_string(), + line: u32::try_from(line_num).unwrap_or(u32::MAX), + rule: code.to_string(), + message: message.to_string(), + severity, + }); + } + + if !any_parsed { + return None; + } + + Some(group_issues("mypy", issues)) +} + +// ============================================================================ +// Tier 2: regex fallback +// ============================================================================ + +/// Parse mypy default text output via regex. +/// +/// Format: `file:line: error: message [code]` +fn try_parse_regex(text: &str) -> Option { + let mut issues: Vec = Vec::new(); + + for line in text.lines() { + if let Some(caps) = RE_MYPY_LINE.captures(line) { + let file = caps[1].to_string(); + let line_num: u32 = caps[2].parse().unwrap_or(0); + let severity = match &caps[3] { + "error" => LintSeverity::Error, + "warning" => LintSeverity::Warning, + "note" => LintSeverity::Info, + _ => LintSeverity::Error, + }; + let message = caps[4].to_string(); + let code = caps + .get(5) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "(unknown)".to_string()); + + issues.push(LintIssue { + file, + line: line_num, + rule: code, + message, + severity, + }); + } + } + + if issues.is_empty() { + return None; + } + + Some(group_issues("mypy", issues)) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn load_fixture(name: &str) -> String { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/lint"); + path.push(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + #[test] + fn test_tier1_mypy_fail() { + let input = load_fixture("mypy_fail.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 NDJSON parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 3); + assert_eq!(result.warnings, 0); + } + + #[test] + fn test_tier2_mypy_regex() { + let input = load_fixture("mypy_text.txt"); + let result = try_parse_regex(&input); + assert!(result.is_some(), "Expected Tier 2 regex parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 3); + } + + #[test] + fn test_parse_impl_json_produces_full() { + let input = load_fixture("mypy_fail.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_full()); + } + + #[test] + fn test_parse_impl_text_produces_degraded() { + let input = load_fixture("mypy_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_degraded(), + "Expected Degraded parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "random garbage".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_passthrough()); + } +} diff --git a/crates/rskim/src/cmd/lint/ruff.rs b/crates/rskim/src/cmd/lint/ruff.rs new file mode 100644 index 0000000..1f03ccb --- /dev/null +++ b/crates/rskim/src/cmd/lint/ruff.rs @@ -0,0 +1,242 @@ +//! Ruff linter parser with three-tier degradation (#104). +//! +//! Executes `ruff check` and parses the output into a structured `LintResult`. +//! +//! Three tiers: +//! - **Tier 1 (Full)**: JSON array parsing (`--output-format json`) +//! - **Tier 2 (Degraded)**: Regex on default text output +//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::{combine_stdout_stderr, group_issues, LinterConfig}; + +const CONFIG: LinterConfig<'static> = LinterConfig { + program: "ruff", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install ruff: pip install ruff", +}; + +// Static regex pattern compiled once via LazyLock. +static RE_RUFF_LINE: LazyLock = + LazyLock::new(|| Regex::new(r"^(.+):(\d+):\d+:\s+(\S+)\s+(.+)").unwrap()); + +/// Run `skim lint ruff [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_linter( + CONFIG, + args, + show_stats, + json_output, + prepare_args, + parse_impl, + ) +} + +/// Ensure "check" subcommand is present and inject `--output-format json`. +fn prepare_args(cmd_args: &mut Vec) { + // Ensure "check" subcommand is present if args don't start with it + if cmd_args.first().is_none_or(|a| a != "check") { + cmd_args.insert(0, "check".to_string()); + } + + // Inject --output-format json if not already present + if !user_has_flag(cmd_args, &["--output-format"]) { + cmd_args.push("--output-format".to_string()); + cmd_args.push("json".to_string()); + } +} + +/// Three-tier parse function for ruff output. +fn parse_impl(output: &CommandOutput) -> ParseResult { + if let Some(result) = try_parse_json(&output.stdout) { + return ParseResult::Full(result); + } + + let combined = combine_stdout_stderr(output); + + if let Some(result) = try_parse_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + ParseResult::Passthrough(combined.into_owned()) +} + +// ============================================================================ +// Tier 1: JSON parsing +// ============================================================================ + +/// Parse ruff JSON output format. +/// +/// Ruff `--output-format json` produces an array: +/// ```json +/// [{"code": "F401", "message": "...", "filename": "...", "location": {"row": 1}}] +/// ``` +fn try_parse_json(stdout: &str) -> Option { + let arr: Vec = serde_json::from_str(stdout.trim()).ok()?; + + // An empty array is valid — means clean run + let mut issues: Vec = Vec::new(); + + for entry in &arr { + let Some(code) = entry.get("code").and_then(|v| v.as_str()) else { + continue; + }; + let Some(message) = entry.get("message").and_then(|v| v.as_str()) else { + continue; + }; + let Some(filename) = entry.get("filename").and_then(|v| v.as_str()) else { + continue; + }; + let Some(location) = entry.get("location") else { + continue; + }; + let Some(row) = location.get("row").and_then(|v| v.as_u64()) else { + continue; + }; + + issues.push(LintIssue { + file: filename.to_string(), + line: u32::try_from(row).unwrap_or(u32::MAX), + rule: code.to_string(), + message: message.to_string(), + // Ruff issues are all errors by default (no severity field) + severity: LintSeverity::Error, + }); + } + + Some(group_issues("ruff", issues)) +} + +// ============================================================================ +// Tier 2: regex fallback +// ============================================================================ + +/// Parse ruff default text output via regex. +/// +/// Format: `file:line:col: CODE message` +fn try_parse_regex(text: &str) -> Option { + let mut issues: Vec = Vec::new(); + + for line in text.lines() { + if let Some(caps) = RE_RUFF_LINE.captures(line) { + let file = caps[1].to_string(); + let line_num: u32 = caps[2].parse().unwrap_or(0); + let code = caps[3].to_string(); + let message = caps[4].to_string(); + + issues.push(LintIssue { + file, + line: line_num, + rule: code, + message, + severity: LintSeverity::Error, + }); + } + } + + if issues.is_empty() { + return None; + } + + Some(group_issues("ruff", issues)) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn load_fixture(name: &str) -> String { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/lint"); + path.push(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + #[test] + fn test_tier1_ruff_fail() { + let input = load_fixture("ruff_fail.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 3); + assert_eq!(result.warnings, 0); + } + + #[test] + fn test_tier1_ruff_clean() { + let result = try_parse_json("[]"); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result.errors, 0); + assert_eq!(result.warnings, 0); + assert!(result.as_ref().contains("LINT OK")); + } + + #[test] + fn test_tier2_ruff_regex() { + let input = load_fixture("ruff_text.txt"); + let result = try_parse_regex(&input); + assert!(result.is_some(), "Expected Tier 2 regex parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 3); + } + + #[test] + fn test_parse_impl_json_produces_full() { + let input = load_fixture("ruff_fail.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_full()); + } + + #[test] + fn test_parse_impl_text_produces_degraded() { + let input = load_fixture("ruff_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_degraded(), + "Expected Degraded parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "random garbage".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_passthrough()); + } +} diff --git a/crates/rskim/src/cmd/mod.rs b/crates/rskim/src/cmd/mod.rs index ed5908b..ba2297a 100644 --- a/crates/rskim/src/cmd/mod.rs +++ b/crates/rskim/src/cmd/mod.rs @@ -15,6 +15,7 @@ mod hooks; mod init; mod integrity; mod learn; +mod lint; mod rewrite; mod session; mod stats; @@ -38,6 +39,7 @@ pub(crate) const KNOWN_SUBCOMMANDS: &[&str] = &[ "git", "init", "learn", + "lint", "rewrite", "stats", "test", @@ -92,6 +94,16 @@ pub(crate) fn inject_flag_before_separator(args: &mut Vec, flag: &str) { } } +/// Controls the output format of parsed command results. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum OutputFormat { + /// Render the parsed result as human-readable text (default). + #[default] + Text, + /// Serialize the parsed result as JSON (for `--json` flag). + Json, +} + /// Configuration for running an external command with parsed output. /// /// Groups the cross-cutting parameters for [`run_parsed_command_with_mode`] @@ -104,6 +116,7 @@ pub(crate) struct ParsedCommandConfig<'a> { pub use_stdin: bool, pub show_stats: bool, pub command_type: crate::analytics::CommandType, + pub output_format: OutputFormat, } /// Execute an external command, parse its output, and emit the result. @@ -122,7 +135,7 @@ pub(crate) fn run_parsed_command( parse: impl FnOnce(&CommandOutput, &[String]) -> ParseResult, ) -> anyhow::Result where - T: AsRef, + T: AsRef + serde::Serialize, { let use_stdin = !io::stdin().is_terminal(); let config = ParsedCommandConfig { @@ -133,6 +146,7 @@ where use_stdin, show_stats, command_type, + output_format: OutputFormat::default(), }; run_parsed_command_with_mode(config, parse) } @@ -155,7 +169,7 @@ pub(crate) fn run_parsed_command_with_mode( parse: impl FnOnce(&CommandOutput, &[String]) -> ParseResult, ) -> anyhow::Result where - T: AsRef, + T: AsRef + serde::Serialize, { /// Maximum bytes we will read from stdin (64 MiB), consistent with the /// runner's `MAX_OUTPUT_BYTES` limit for command output pipes. @@ -169,6 +183,7 @@ where use_stdin, show_stats, command_type, + output_format, } = config; let output = if use_stdin { @@ -218,36 +233,43 @@ where let result = parse(&output, args); // Emit markers (warnings/notices) to stderr - let stderr_stream = io::stderr(); - let mut stderr_handle = stderr_stream.lock(); - let _ = result.emit_markers(&mut stderr_handle); - drop(stderr_handle); + let _ = result.emit_markers(&mut io::stderr().lock()); - // Emit content to stdout - let stdout_stream = io::stdout(); - let mut stdout_handle = stdout_stream.lock(); - write!(stdout_handle, "{}", result.content())?; - // Ensure trailing newline - if !result.content().is_empty() && !result.content().ends_with('\n') { - writeln!(stdout_handle)?; - } - stdout_handle.flush()?; + // Capture exit code before moving stdout into analytics + let code = output.exit_code.unwrap_or(1); + + // Render output and capture the compressed content string for stats/analytics. + let compressed: String = match output_format { + OutputFormat::Json => { + let json_str = result.to_json_envelope()?; + let mut handle = io::stdout().lock(); + writeln!(handle, "{json_str}")?; + handle.flush()?; + json_str + } + OutputFormat::Text => { + let content = result.content(); + let mut handle = io::stdout().lock(); + write!(handle, "{content}")?; + if !content.is_empty() && !content.ends_with('\n') { + writeln!(handle)?; + } + handle.flush()?; + content.to_string() + } + }; - // Report token stats if requested if show_stats { - let (orig, comp) = crate::process::count_token_pair(&output.stdout, result.content()); + let (orig, comp) = crate::process::count_token_pair(&output.stdout, &compressed); crate::process::report_token_stats(orig, comp, ""); } - // Capture exit code before moving stdout into analytics - let code = output.exit_code.unwrap_or(1); - // Record analytics (fire-and-forget, non-blocking). - // Guard to avoid .to_string() allocation when analytics are disabled. + // Guard to avoid allocation when analytics are disabled. if crate::analytics::is_analytics_enabled() { crate::analytics::try_record_command( output.stdout, - result.content().to_string(), + compressed, format!("skim {program} {}", args.join(" ")), command_type, output.duration, @@ -284,6 +306,7 @@ pub(crate) fn dispatch(subcommand: &str, args: &[String]) -> anyhow::Result git::run(args), "init" => init::run(args), "learn" => learn::run(args), + "lint" => lint::run(args), "rewrite" => rewrite::run(args), "stats" => stats::run(args), "test" => test::run(args), diff --git a/crates/rskim/src/cmd/rewrite.rs b/crates/rskim/src/cmd/rewrite.rs index 03f05e0..0e83ca7 100644 --- a/crates/rskim/src/cmd/rewrite.rs +++ b/crates/rskim/src/cmd/rewrite.rs @@ -34,6 +34,7 @@ enum RewriteCategory { Build, Git, Read, + Lint, } struct RewriteRule { @@ -122,7 +123,7 @@ fn serialize_category( } // ============================================================================ -// Rule table (15 rules, ordered longest-prefix-first within same leading token) +// Rule table (24 rules, ordered longest-prefix-first within same leading token) // ============================================================================ const REWRITE_RULES: &[RewriteRule] = &[ @@ -222,6 +223,64 @@ const REWRITE_RULES: &[RewriteRule] = &[ skip_if_flag_prefix: &[], category: RewriteCategory::Build, }, + // lint — eslint + RewriteRule { + prefix: &["npx", "eslint"], + rewrite_to: &["skim", "lint", "eslint"], + skip_if_flag_prefix: &["--format", "-f"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["eslint"], + rewrite_to: &["skim", "lint", "eslint"], + skip_if_flag_prefix: &["--format", "-f"], + category: RewriteCategory::Lint, + }, + // lint — ruff + RewriteRule { + prefix: &["ruff", "check"], + rewrite_to: &["skim", "lint", "ruff"], + skip_if_flag_prefix: &["--output-format"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["ruff"], + rewrite_to: &["skim", "lint", "ruff"], + skip_if_flag_prefix: &["--output-format"], + category: RewriteCategory::Lint, + }, + // lint — mypy (longest prefix first: python3 -m mypy, python -m mypy, mypy) + RewriteRule { + prefix: &["python3", "-m", "mypy"], + rewrite_to: &["skim", "lint", "mypy"], + skip_if_flag_prefix: &["--output"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["python", "-m", "mypy"], + rewrite_to: &["skim", "lint", "mypy"], + skip_if_flag_prefix: &["--output"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["mypy"], + rewrite_to: &["skim", "lint", "mypy"], + skip_if_flag_prefix: &["--output"], + category: RewriteCategory::Lint, + }, + // lint — golangci-lint + RewriteRule { + prefix: &["golangci-lint", "run"], + rewrite_to: &["skim", "lint", "golangci"], + skip_if_flag_prefix: &["--out-format"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["golangci-lint"], + rewrite_to: &["skim", "lint", "golangci"], + skip_if_flag_prefix: &["--out-format"], + category: RewriteCategory::Lint, + }, ]; // ============================================================================ diff --git a/crates/rskim/src/cmd/test/cargo.rs b/crates/rskim/src/cmd/test/cargo.rs index 0573df5..d0cc3dc 100644 --- a/crates/rskim/src/cmd/test/cargo.rs +++ b/crates/rskim/src/cmd/test/cargo.rs @@ -22,7 +22,8 @@ use std::sync::LazyLock; use regex::Regex; use crate::cmd::{ - inject_flag_before_separator, run_parsed_command_with_mode, user_has_flag, ParsedCommandConfig, + inject_flag_before_separator, run_parsed_command_with_mode, user_has_flag, OutputFormat, + ParsedCommandConfig, }; use crate::output::canonical::{TestEntry, TestOutcome, TestResult, TestSummary}; use crate::output::ParseResult; @@ -73,6 +74,7 @@ pub(crate) fn run(args: &[String], show_stats: bool) -> anyhow::Result use_stdin, show_stats, command_type: crate::analytics::CommandType::Test, + output_format: OutputFormat::default(), }, move |output, _args| parse_impl(output, is_nextest), ) diff --git a/crates/rskim/src/output/canonical.rs b/crates/rskim/src/output/canonical.rs index 91801b1..ca5ea98 100644 --- a/crates/rskim/src/output/canonical.rs +++ b/crates/rskim/src/output/canonical.rs @@ -265,6 +265,120 @@ impl fmt::Display for BuildResult { } } +// ============================================================================ +// LintResult types +// ============================================================================ + +/// Severity level for a lint issue +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum LintSeverity { + Error, + Warning, + Info, +} + +impl fmt::Display for LintSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LintSeverity::Error => write!(f, "error"), + LintSeverity::Warning => write!(f, "warning"), + LintSeverity::Info => write!(f, "info"), + } + } +} + +/// A single lint issue from any linter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LintIssue { + pub(crate) file: String, + pub(crate) line: u32, + pub(crate) rule: String, + pub(crate) message: String, + pub(crate) severity: LintSeverity, +} + +/// A group of lint issues sharing the same rule +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LintGroup { + pub(crate) rule: String, + pub(crate) count: usize, + pub(crate) severity: LintSeverity, + pub(crate) locations: Vec, +} + +/// Complete lint result with summary and grouped issues +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LintResult { + pub(crate) tool: String, + pub(crate) errors: usize, + pub(crate) warnings: usize, + pub(crate) groups: Vec, + #[serde(default)] + rendered: String, +} + +impl LintResult { + /// Create a new LintResult with pre-computed rendered output + pub(crate) fn new( + tool: String, + errors: usize, + warnings: usize, + groups: Vec, + ) -> Self { + let rendered = Self::render(&tool, errors, warnings, &groups); + Self { + tool, + errors, + warnings, + groups, + rendered, + } + } + + /// Recompute rendered field if empty (e.g., after deserialization) + pub(crate) fn ensure_rendered(&mut self) { + if self.rendered.is_empty() { + self.rendered = Self::render(&self.tool, self.errors, self.warnings, &self.groups); + } + } + + fn render(tool: &str, errors: usize, warnings: usize, groups: &[LintGroup]) -> String { + use std::fmt::Write; + + let total = errors + warnings; + if total == 0 { + return format!("LINT OK | {tool} | 0 issues"); + } + + let mut output = format!("LINT: {errors} errors, {warnings} warnings | {tool}"); + for group in groups { + let suffix = if group.count == 1 { "" } else { "s" }; + let _ = write!( + output, + "\n {} ({} {}{suffix}):", + group.rule, group.count, group.severity + ); + for loc in &group.locations { + let _ = write!(output, "\n {loc}"); + } + } + + output + } +} + +impl AsRef for LintResult { + fn as_ref(&self) -> &str { + &self.rendered + } +} + +impl fmt::Display for LintResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.rendered) + } +} + // ============================================================================ // Helpers // ============================================================================ @@ -531,4 +645,82 @@ mod tests { assert_eq!(format!("{}", TestOutcome::Fail), "FAIL"); assert_eq!(format!("{}", TestOutcome::Skip), "SKIP"); } + + // ======================================================================== + // LintResult Display tests + // ======================================================================== + + #[test] + fn test_lint_result_display_clean() { + let result = LintResult::new("eslint".to_string(), 0, 0, vec![]); + assert_eq!(format!("{result}"), "LINT OK | eslint | 0 issues"); + } + + #[test] + fn test_lint_result_display_with_issues() { + let groups = vec![ + LintGroup { + rule: "no-unused-vars".to_string(), + count: 3, + severity: LintSeverity::Warning, + locations: vec![ + "src/api/auth.ts:12".to_string(), + "src/api/users.ts:34".to_string(), + "src/utils/format.ts:8".to_string(), + ], + }, + LintGroup { + rule: "@typescript-eslint/no-explicit-any".to_string(), + count: 2, + severity: LintSeverity::Error, + locations: vec![ + "src/types.ts:45".to_string(), + "src/api/client.ts:89".to_string(), + ], + }, + ]; + let result = LintResult::new("eslint".to_string(), 2, 3, groups); + let display = format!("{result}"); + assert!(display.contains("LINT: 2 errors, 3 warnings | eslint")); + assert!(display.contains("no-unused-vars (3 warnings):")); + assert!(display.contains("src/api/auth.ts:12")); + assert!(display.contains("@typescript-eslint/no-explicit-any (2 errors):")); + assert!(display.contains("src/types.ts:45")); + } + + #[test] + fn test_lint_result_serde_roundtrip() { + let groups = vec![LintGroup { + rule: "F401".to_string(), + count: 2, + severity: LintSeverity::Error, + locations: vec!["src/main.py:1".to_string(), "src/main.py:2".to_string()], + }]; + let original = LintResult::new("ruff".to_string(), 2, 0, groups); + let json = serde_json::to_string(&original).unwrap(); + let mut deserialized: LintResult = serde_json::from_str(&json).unwrap(); + deserialized.ensure_rendered(); + assert_eq!(format!("{original}"), format!("{deserialized}")); + } + + #[test] + fn test_lint_result_ensure_rendered_recomputes_when_empty() { + let mut result = LintResult { + tool: "mypy".to_string(), + errors: 0, + warnings: 0, + groups: vec![], + rendered: String::new(), + }; + assert_eq!(result.as_ref(), ""); + result.ensure_rendered(); + assert_eq!(result.as_ref(), "LINT OK | mypy | 0 issues"); + } + + #[test] + fn test_lint_severity_display() { + assert_eq!(format!("{}", LintSeverity::Error), "error"); + assert_eq!(format!("{}", LintSeverity::Warning), "warning"); + assert_eq!(format!("{}", LintSeverity::Info), "info"); + } } diff --git a/crates/rskim/src/output/mod.rs b/crates/rskim/src/output/mod.rs index c0e9b2e..efadd35 100644 --- a/crates/rskim/src/output/mod.rs +++ b/crates/rskim/src/output/mod.rs @@ -88,6 +88,34 @@ impl> ParseResult { } } +impl ParseResult { + /// Serialize the parse result as a JSON string suitable for `--json` output. + /// + /// - `Full(result)` -> direct JSON serialization (no envelope -- preserves existing behavior) + /// - `Degraded(result, warnings)` -> `{"tier":"degraded","warnings":[...],"result":{...}}` + /// - `Passthrough(raw)` -> `{"tier":"passthrough","raw":"..."}` + pub(crate) fn to_json_envelope(&self) -> serde_json::Result { + match self { + ParseResult::Full(inner) => serde_json::to_string(inner), + ParseResult::Degraded(inner, warnings) => { + let val = serde_json::json!({ + "tier": "degraded", + "warnings": warnings, + "result": inner, + }); + serde_json::to_string(&val) + } + ParseResult::Passthrough(raw) => { + let val = serde_json::json!({ + "tier": "passthrough", + "raw": raw, + }); + serde_json::to_string(&val) + } + } + } +} + impl> ParseResult { /// Consuming access to inner content as `String`. pub(crate) fn into_content(self) -> String { @@ -929,4 +957,61 @@ mod tests { "expected ~100% savings for huge input with zero output, got: {output}" ); } + + // ======================================================================== + // to_json_envelope tests + // ======================================================================== + + /// Minimal type for testing to_json_envelope without coupling to canonical types. + #[derive(Debug, Clone, serde::Serialize)] + struct StubResult { + name: String, + count: usize, + } + + #[test] + fn test_to_json_envelope_full_no_wrapper() { + let inner = StubResult { + name: "test".to_string(), + count: 42, + }; + let result: ParseResult = ParseResult::Full(inner); + let json_str = result.to_json_envelope().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + // Full: direct serialization, no envelope + assert_eq!(parsed["name"], "test"); + assert_eq!(parsed["count"], 42); + assert!(parsed.get("tier").is_none(), "Full should have no tier key"); + } + + #[test] + fn test_to_json_envelope_degraded_has_envelope() { + let inner = StubResult { + name: "partial".to_string(), + count: 7, + }; + let warnings = vec!["regex fallback".to_string(), "missing field".to_string()]; + let result: ParseResult = ParseResult::Degraded(inner, warnings); + let json_str = result.to_json_envelope().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed["tier"], "degraded"); + assert_eq!(parsed["warnings"][0], "regex fallback"); + assert_eq!(parsed["warnings"][1], "missing field"); + assert_eq!(parsed["result"]["name"], "partial"); + assert_eq!(parsed["result"]["count"], 7); + } + + #[test] + fn test_to_json_envelope_passthrough_has_envelope() { + let result: ParseResult = + ParseResult::Passthrough("raw output here".to_string()); + let json_str = result.to_json_envelope().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed["tier"], "passthrough"); + assert_eq!(parsed["raw"], "raw output here"); + assert!( + parsed.get("result").is_none(), + "Passthrough should have no result key" + ); + } } diff --git a/crates/rskim/tests/cli_e2e_lint_parsers.rs b/crates/rskim/tests/cli_e2e_lint_parsers.rs new file mode 100644 index 0000000..952f91f --- /dev/null +++ b/crates/rskim/tests/cli_e2e_lint_parsers.rs @@ -0,0 +1,343 @@ +//! E2E tests for lint parser degradation tiers (#104). +//! +//! Tests each linter at different degradation tiers via stdin piping, +//! verifying structured output markers and stderr diagnostics. +//! +//! Tier behavior reference (from emit_markers in output/mod.rs): +//! - Full: no stderr markers +//! - Degraded: "[warning] ..." on stderr +//! - Passthrough: "[notice] output passed through without parsing" on stderr + +use assert_cmd::Command; +use predicates::prelude::*; + +fn skim_cmd() -> Command { + Command::cargo_bin("skim").unwrap() +} + +// ============================================================================ +// ESLint: Tier 1 (JSON) -- Full +// ============================================================================ + +#[test] +fn test_eslint_tier1_json_pass() { + let fixture = include_str!("fixtures/cmd/lint/eslint_pass.json"); + skim_cmd() + .args(["lint", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT OK")); +} + +#[test] +fn test_eslint_tier1_json_fail() { + let fixture = include_str!("fixtures/cmd/lint/eslint_fail.json"); + skim_cmd() + .args(["lint", "eslint"]) + .write_stdin(fixture) + .assert() + .code(0) // stdin mode always exits 0 + .stdout(predicate::str::contains("LINT:")) + .stdout(predicate::str::contains("2 errors")) + .stdout(predicate::str::contains("3 warnings")); +} + +// ============================================================================ +// ESLint: Tier 2 (regex) -- Degraded +// ============================================================================ + +#[test] +fn test_eslint_tier2_regex_degraded() { + let fixture = include_str!("fixtures/cmd/lint/eslint_text.txt"); + skim_cmd() + .args(["lint", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// ESLint: Tier 3 (passthrough) +// ============================================================================ + +#[test] +fn test_eslint_tier3_passthrough_garbage() { + skim_cmd() + .args(["lint", "eslint"]) + .write_stdin("random garbage not eslint output\n") + .assert() + .success() + .stdout(predicate::str::contains("random garbage")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// ESLint: --json flag +// ============================================================================ + +#[test] +fn test_eslint_json_flag_full() { + let fixture = include_str!("fixtures/cmd/lint/eslint_fail.json"); + skim_cmd() + .args(["lint", "--json", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"eslint\"")) + .stdout(predicate::str::contains("\"errors\":2")); +} + +#[test] +fn test_eslint_json_flag_degraded() { + let fixture = include_str!("fixtures/cmd/lint/eslint_text.txt"); + skim_cmd() + .args(["lint", "--json", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tier\":\"degraded\"")) + .stdout(predicate::str::contains("\"tool\":\"eslint\"")); +} + +// ============================================================================ +// Ruff: Tier 1 (JSON) -- Full +// ============================================================================ + +#[test] +fn test_ruff_tier1_json_fail() { + let fixture = include_str!("fixtures/cmd/lint/ruff_fail.json"); + skim_cmd() + .args(["lint", "ruff"]) + .write_stdin(fixture) + .assert() + .code(0) + .stdout(predicate::str::contains("LINT:")) + .stdout(predicate::str::contains("3 errors")); +} + +// ============================================================================ +// Ruff: Tier 2 (regex) -- Degraded +// ============================================================================ + +#[test] +fn test_ruff_tier2_regex_degraded() { + let fixture = include_str!("fixtures/cmd/lint/ruff_text.txt"); + skim_cmd() + .args(["lint", "ruff"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// Ruff: Tier 3 (passthrough) +// ============================================================================ + +#[test] +fn test_ruff_tier3_passthrough_garbage() { + skim_cmd() + .args(["lint", "ruff"]) + .write_stdin("random garbage not ruff output\n") + .assert() + .success() + .stdout(predicate::str::contains("random garbage")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// Ruff: --json flag +// ============================================================================ + +#[test] +fn test_ruff_json_flag_full() { + let fixture = include_str!("fixtures/cmd/lint/ruff_fail.json"); + skim_cmd() + .args(["lint", "--json", "ruff"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"ruff\"")) + .stdout(predicate::str::contains("\"errors\":3")); +} + +// ============================================================================ +// mypy: Tier 1 (NDJSON) -- Full +// ============================================================================ + +#[test] +fn test_mypy_tier1_json_fail() { + let fixture = include_str!("fixtures/cmd/lint/mypy_fail.json"); + skim_cmd() + .args(["lint", "mypy"]) + .write_stdin(fixture) + .assert() + .code(0) + .stdout(predicate::str::contains("LINT:")) + .stdout(predicate::str::contains("3 errors")); +} + +// ============================================================================ +// mypy: Tier 2 (regex) -- Degraded +// ============================================================================ + +#[test] +fn test_mypy_tier2_regex_degraded() { + let fixture = include_str!("fixtures/cmd/lint/mypy_text.txt"); + skim_cmd() + .args(["lint", "mypy"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// mypy: Tier 3 (passthrough) +// ============================================================================ + +#[test] +fn test_mypy_tier3_passthrough_garbage() { + skim_cmd() + .args(["lint", "mypy"]) + .write_stdin("random garbage not mypy output\n") + .assert() + .success() + .stdout(predicate::str::contains("random garbage")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// mypy: --json flag +// ============================================================================ + +#[test] +fn test_mypy_json_flag_full() { + let fixture = include_str!("fixtures/cmd/lint/mypy_fail.json"); + skim_cmd() + .args(["lint", "--json", "mypy"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"mypy\"")) + .stdout(predicate::str::contains("\"errors\":3")); +} + +// ============================================================================ +// golangci-lint: Tier 1 (JSON) -- Full +// ============================================================================ + +#[test] +fn test_golangci_tier1_json_fail() { + let fixture = include_str!("fixtures/cmd/lint/golangci_fail.json"); + skim_cmd() + .args(["lint", "golangci"]) + .write_stdin(fixture) + .assert() + .code(0) + .stdout(predicate::str::contains("LINT:")) + .stdout(predicate::str::contains("1 error")) + .stdout(predicate::str::contains("3 warning")); +} + +// ============================================================================ +// golangci-lint: Tier 2 (regex) -- Degraded +// ============================================================================ + +#[test] +fn test_golangci_tier2_regex_degraded() { + let fixture = include_str!("fixtures/cmd/lint/golangci_text.txt"); + skim_cmd() + .args(["lint", "golangci"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// golangci-lint: Tier 3 (passthrough) +// ============================================================================ + +#[test] +fn test_golangci_tier3_passthrough_garbage() { + skim_cmd() + .args(["lint", "golangci"]) + .write_stdin("random garbage not golangci output\n") + .assert() + .success() + .stdout(predicate::str::contains("random garbage")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// golangci-lint: --json flag +// ============================================================================ + +#[test] +fn test_golangci_json_flag_full() { + let fixture = include_str!("fixtures/cmd/lint/golangci_fail.json"); + skim_cmd() + .args(["lint", "--json", "golangci"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"golangci\"")); +} + +// ============================================================================ +// Dispatcher: help and unknown linter +// ============================================================================ + +#[test] +fn test_lint_help() { + skim_cmd() + .args(["lint", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Available linters:")) + .stdout(predicate::str::contains("eslint")) + .stdout(predicate::str::contains("ruff")) + .stdout(predicate::str::contains("mypy")) + .stdout(predicate::str::contains("golangci")); +} + +#[test] +fn test_lint_unknown_linter() { + skim_cmd() + .args(["lint", "unknown-linter"]) + .assert() + .failure() + .stderr(predicate::str::contains("unknown linter 'unknown-linter'")); +} + +#[test] +fn test_lint_no_args_shows_help() { + skim_cmd() + .args(["lint"]) + .assert() + .success() + .stdout(predicate::str::contains("Available linters:")); +} + +// ============================================================================ +// --show-stats integration +// ============================================================================ + +#[test] +fn test_lint_show_stats_reports_tokens() { + let fixture = include_str!("fixtures/cmd/lint/eslint_fail.json"); + skim_cmd() + .args(["lint", "--show-stats", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("tokens")); +} diff --git a/crates/rskim/tests/cli_e2e_rewrite.rs b/crates/rskim/tests/cli_e2e_rewrite.rs index 3aa7ebc..df2722c 100644 --- a/crates/rskim/tests/cli_e2e_rewrite.rs +++ b/crates/rskim/tests/cli_e2e_rewrite.rs @@ -664,3 +664,97 @@ fn test_rewrite_hook_passthrough_zero_stderr() { "Passthrough hook mode should produce zero stderr, got: {stderr}" ); } + +// ============================================================================ +// Lint rewrite rules (#104) +// ============================================================================ + +#[test] +fn test_rewrite_eslint() { + skim_cmd() + .args(["rewrite", "eslint", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint eslint .")); +} + +#[test] +fn test_rewrite_eslint_skip_format_flag() { + // When user already has --format json, rewrite should be suppressed + skim_cmd() + .args(["rewrite", "eslint", "--format", "json", "."]) + .assert() + .failure(); // No match = exit 1 +} + +#[test] +fn test_rewrite_npx_eslint() { + skim_cmd() + .args(["rewrite", "npx", "eslint", "src/"]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint eslint src/")); +} + +#[test] +fn test_rewrite_ruff_check() { + skim_cmd() + .args(["rewrite", "ruff", "check", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint ruff .")); +} + +#[test] +fn test_rewrite_ruff_bare() { + skim_cmd() + .args(["rewrite", "ruff", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint ruff .")); +} + +#[test] +fn test_rewrite_mypy() { + skim_cmd() + .args(["rewrite", "mypy", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint mypy .")); +} + +#[test] +fn test_rewrite_python_m_mypy() { + skim_cmd() + .args(["rewrite", "python", "-m", "mypy", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint mypy .")); +} + +#[test] +fn test_rewrite_python3_m_mypy() { + skim_cmd() + .args(["rewrite", "python3", "-m", "mypy", "src/"]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint mypy src/")); +} + +#[test] +fn test_rewrite_golangci_lint_run() { + skim_cmd() + .args(["rewrite", "golangci-lint", "run", "./..."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint golangci ./...")); +} + +#[test] +fn test_rewrite_golangci_lint_bare() { + skim_cmd() + .args(["rewrite", "golangci-lint", "./..."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint golangci ./...")); +} diff --git a/crates/rskim/tests/fixtures/cmd/lint/eslint_fail.json b/crates/rskim/tests/fixtures/cmd/lint/eslint_fail.json new file mode 100644 index 0000000..f95e870 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/eslint_fail.json @@ -0,0 +1,21 @@ +[ + { + "filePath": "/home/user/project/src/api/auth.ts", + "messages": [ + {"ruleId": "no-unused-vars", "severity": 1, "message": "'x' is defined but never used.", "line": 12, "column": 7}, + {"ruleId": "@typescript-eslint/no-explicit-any", "severity": 2, "message": "Unexpected any. Specify a different type.", "line": 45, "column": 18} + ], + "errorCount": 1, + "warningCount": 1 + }, + { + "filePath": "/home/user/project/src/utils/format.ts", + "messages": [ + {"ruleId": "no-unused-vars", "severity": 1, "message": "'helper' is defined but never used.", "line": 8, "column": 10}, + {"ruleId": "no-unused-vars", "severity": 1, "message": "'result' is defined but never used.", "line": 22, "column": 7}, + {"ruleId": "eqeqeq", "severity": 2, "message": "Expected '===' and instead saw '=='.", "line": 15, "column": 12} + ], + "errorCount": 1, + "warningCount": 2 + } +] diff --git a/crates/rskim/tests/fixtures/cmd/lint/eslint_pass.json b/crates/rskim/tests/fixtures/cmd/lint/eslint_pass.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/eslint_pass.json @@ -0,0 +1 @@ +[] diff --git a/crates/rskim/tests/fixtures/cmd/lint/eslint_text.txt b/crates/rskim/tests/fixtures/cmd/lint/eslint_text.txt new file mode 100644 index 0000000..da85a3e --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/eslint_text.txt @@ -0,0 +1,9 @@ +/home/user/project/src/api/auth.ts + 12:7 warning 'x' is defined but never used no-unused-vars + 45:18 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/home/user/project/src/utils/format.ts + 8:10 warning 'helper' is defined but never used no-unused-vars + 15:12 error Expected '===' and instead saw '==' eqeqeq + +✖ 4 problems (2 errors, 2 warnings) diff --git a/crates/rskim/tests/fixtures/cmd/lint/golangci_fail.json b/crates/rskim/tests/fixtures/cmd/lint/golangci_fail.json new file mode 100644 index 0000000..ed647a7 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/golangci_fail.json @@ -0,0 +1,6 @@ +{"Issues": [ + {"FromLinter": "govet", "Text": "printf: non-constant format string in call to fmt.Printf", "Pos": {"Filename": "cmd/main.go", "Line": 42, "Column": 12}, "Severity": ""}, + {"FromLinter": "govet", "Text": "unreachable code", "Pos": {"Filename": "cmd/main.go", "Line": 55, "Column": 2}, "Severity": "warning"}, + {"FromLinter": "errcheck", "Text": "Error return value of `os.Remove` is not checked", "Pos": {"Filename": "pkg/cleanup.go", "Line": 18, "Column": 9}, "Severity": ""}, + {"FromLinter": "staticcheck", "Text": "SA1006: printf-style function with dynamic format string", "Pos": {"Filename": "internal/log.go", "Line": 33, "Column": 5}, "Severity": "error"} +]} diff --git a/crates/rskim/tests/fixtures/cmd/lint/golangci_text.txt b/crates/rskim/tests/fixtures/cmd/lint/golangci_text.txt new file mode 100644 index 0000000..f5198f1 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/golangci_text.txt @@ -0,0 +1,4 @@ +cmd/main.go:42:12: printf: non-constant format string in call to fmt.Printf (govet) +cmd/main.go:55:2: unreachable code (govet) +pkg/cleanup.go:18:9: Error return value of `os.Remove` is not checked (errcheck) +internal/log.go:33:5: SA1006: printf-style function with dynamic format string (staticcheck) diff --git a/crates/rskim/tests/fixtures/cmd/lint/mypy_fail.json b/crates/rskim/tests/fixtures/cmd/lint/mypy_fail.json new file mode 100644 index 0000000..8eb75b5 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/mypy_fail.json @@ -0,0 +1,3 @@ +{"file": "src/main.py", "line": 10, "column": 5, "message": "Incompatible return value type (got \"str\", expected \"int\")", "hint": null, "code": "return-value", "severity": "error"} +{"file": "src/main.py", "line": 25, "column": 12, "message": "\"None\" has no attribute \"strip\"", "hint": null, "code": "union-attr", "severity": "error"} +{"file": "src/utils.py", "line": 3, "column": 1, "message": "Cannot find implementation or library stub for module named \"nonexistent\"", "hint": null, "code": "import", "severity": "error"} diff --git a/crates/rskim/tests/fixtures/cmd/lint/mypy_text.txt b/crates/rskim/tests/fixtures/cmd/lint/mypy_text.txt new file mode 100644 index 0000000..bdadbe8 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/mypy_text.txt @@ -0,0 +1,4 @@ +src/main.py:10: error: Incompatible return value type (got "str", expected "int") [return-value] +src/main.py:25: error: "None" has no attribute "strip" [union-attr] +src/utils.py:3: error: Cannot find implementation or library stub for module named "nonexistent" [import] +Found 3 errors in 2 files (checked 5 source files) diff --git a/crates/rskim/tests/fixtures/cmd/lint/ruff_fail.json b/crates/rskim/tests/fixtures/cmd/lint/ruff_fail.json new file mode 100644 index 0000000..782b02c --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/ruff_fail.json @@ -0,0 +1,5 @@ +[ + {"code": "F401", "message": "`os` imported but unused", "filename": "src/main.py", "location": {"row": 1, "column": 1}}, + {"code": "F401", "message": "`sys` imported but unused", "filename": "src/main.py", "location": {"row": 2, "column": 1}}, + {"code": "E501", "message": "Line too long (120 > 88)", "filename": "src/utils.py", "location": {"row": 45, "column": 89}} +] diff --git a/crates/rskim/tests/fixtures/cmd/lint/ruff_text.txt b/crates/rskim/tests/fixtures/cmd/lint/ruff_text.txt new file mode 100644 index 0000000..9e4b645 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/ruff_text.txt @@ -0,0 +1,4 @@ +src/main.py:1:1: F401 `os` imported but unused +src/main.py:2:1: F401 `sys` imported but unused +src/utils.py:45:89: E501 Line too long (120 > 88) +Found 3 errors.