Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0b898c5
feat: #104 skim lint — linter parsers (eslint, ruff, mypy, golangci-l…
Mar 28, 2026
608791a
style: cargo fmt lint modules
Mar 28, 2026
4e71269
feat: #105 skim pkg — package manager parsers (npm, pnpm, pip, cargo …
Mar 28, 2026
ea5ae43
fix: resolve review issues — extract shared json helper, fix parsing …
Mar 28, 2026
29e1086
test: strengthen lint parser assertions and add helper unit tests
Mar 28, 2026
3b4002a
refactor: simplify lint parser resolution code
Mar 28, 2026
fde2b0c
fix: add pkg rewrite unit tests and missing aliases
Mar 28, 2026
e667475
fix: resolve pkg parser review issues — extract combine_output, fix p…
Mar 28, 2026
b7b20bc
refactor: simplify pkg parser resolution code
Mar 28, 2026
fe4fe15
refactor: split npm.rs into submodules, extract cargo audit helper (#…
Mar 28, 2026
00c094e
style: remove redundant section banners from npm submodules
Mar 28, 2026
3383877
fix: correct misleading doc comment on extract_vuln_detail
Mar 28, 2026
a318630
fix(lint): use saturating u64-to-u32 cast for line numbers
Mar 28, 2026
a3e95f4
test(pkg): add unit tests for helpers and three-tier integration
Mar 28, 2026
25fc051
fix(pkg): remove misleading Option wrapper, use safe integer conversion
Mar 28, 2026
6027db9
fix(lint): resolve review batch-c — render alloc, degraded tests, docs
Mar 28, 2026
8a4a700
refactor: simplify lint parser resolution code
Mar 28, 2026
d1f5331
refactor: extract json_usize helper in npm install parser
Mar 28, 2026
127e7af
refactor: unify lint JSON mode into shared infrastructure, extract ru…
Mar 29, 2026
55326c5
refactor: extract run_pkg_subcommand helper, block-based cargo audit …
Mar 29, 2026
2ac0f26
fix: address self-review issues
Mar 29, 2026
0d52446
fix: remove dead RE_NO_VULNS regex and unused imports in cargo audit …
Mar 29, 2026
de1f59b
merge: consolidate PR #106 (lint parsers) and PR #107 (pkg parsers) w…
Mar 29, 2026
ea9350c
fix: remove accidentally staged worktree references
Mar 29, 2026
4c57f7d
style: fix rustfmt formatting in lint/mod.rs
Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/rskim/src/analytics/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub(crate) enum CommandType {
Test,
Build,
Git,
Lint,
Pkg,
}

impl CommandType {
Expand All @@ -48,6 +50,8 @@ impl CommandType {
CommandType::Test => "test",
CommandType::Build => "build",
CommandType::Git => "git",
CommandType::Lint => "lint",
CommandType::Pkg => "pkg",
}
}
}
Expand Down
278 changes: 278 additions & 0 deletions crates/rskim/src/cmd/lint/eslint.rs
Original file line number Diff line number Diff line change
@@ -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<Regex> = 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<Regex> =
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<std::process::ExitCode> {
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<String>) {
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<LintResult> {
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<LintResult> {
let arr: Vec<serde_json::Value> = serde_json::from_str(stdout.trim()).ok()?;

let mut issues: Vec<LintIssue> = 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<LintResult> {
let mut issues: Vec<LintIssue> = 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()
);
}
}
Loading
Loading