use std::path::Path; use compliance_core::models::{Finding, ScanType, Severity}; use compliance_core::CoreError; use tokio::process::Command; use crate::pipeline::dedup; use super::run_with_timeout; pub(super) async fn run_eslint(repo_path: &Path, repo_id: &str) -> Result, CoreError> { // Use the project-local eslint binary directly, not npx (which can hang downloading) let eslint_bin = repo_path.join("node_modules/.bin/eslint"); let child = Command::new(eslint_bin) .args([".", "--format", "json", "--no-error-on-unmatched-pattern"]) .current_dir(repo_path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| CoreError::Scanner { scanner: "eslint".to_string(), source: Box::new(e), })?; let output = run_with_timeout(child, "eslint").await?; if output.stdout.is_empty() { return Ok(Vec::new()); } let results: Vec = serde_json::from_slice(&output.stdout).unwrap_or_default(); let mut findings = Vec::new(); for file_result in results { for msg in file_result.messages { let severity = match msg.severity { 2 => Severity::Medium, _ => Severity::Low, }; let rule_id = msg.rule_id.unwrap_or_default(); let fingerprint = dedup::compute_fingerprint(&[ repo_id, "eslint", &rule_id, &file_result.file_path, &msg.line.to_string(), ]); let mut finding = Finding::new( repo_id.to_string(), fingerprint, "eslint".to_string(), ScanType::Lint, format!("[eslint] {}", msg.message), msg.message, severity, ); finding.rule_id = Some(rule_id); finding.file_path = Some(file_result.file_path.clone()); finding.line_number = Some(msg.line); findings.push(finding); } } Ok(findings) } #[derive(serde::Deserialize)] struct EslintFileResult { #[serde(rename = "filePath")] file_path: String, messages: Vec, } #[derive(serde::Deserialize)] struct EslintMessage { #[serde(rename = "ruleId")] rule_id: Option, severity: u8, message: String, line: u32, } #[cfg(test)] mod tests { use super::*; #[test] fn deserialize_eslint_output() { let json = r#"[ { "filePath": "/home/user/project/src/app.js", "messages": [ { "ruleId": "no-unused-vars", "severity": 2, "message": "'x' is defined but never used.", "line": 10 }, { "ruleId": "semi", "severity": 1, "message": "Missing semicolon.", "line": 15 } ] } ]"#; let results: Vec = serde_json::from_str(json).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].file_path, "/home/user/project/src/app.js"); assert_eq!(results[0].messages.len(), 2); assert_eq!( results[0].messages[0].rule_id, Some("no-unused-vars".to_string()) ); assert_eq!(results[0].messages[0].severity, 2); assert_eq!(results[0].messages[0].line, 10); assert_eq!(results[0].messages[1].severity, 1); } #[test] fn deserialize_eslint_null_rule_id() { let json = r#"[ { "filePath": "src/index.js", "messages": [ { "ruleId": null, "severity": 2, "message": "Parsing error: Unexpected token", "line": 1 } ] } ]"#; let results: Vec = serde_json::from_str(json).unwrap(); assert_eq!(results[0].messages[0].rule_id, None); } #[test] fn deserialize_eslint_empty_messages() { let json = r#"[{"filePath": "src/clean.js", "messages": []}]"#; let results: Vec = serde_json::from_str(json).unwrap(); assert_eq!(results[0].messages.len(), 0); } #[test] fn deserialize_eslint_empty_array() { let json = "[]"; let results: Vec = serde_json::from_str(json).unwrap(); assert!(results.is_empty()); } #[test] fn eslint_severity_mapping() { // severity 2 = error -> Medium, anything else -> Low assert_eq!( match 2u8 { 2 => "Medium", _ => "Low", }, "Medium" ); assert_eq!( match 1u8 { 2 => "Medium", _ => "Low", }, "Low" ); assert_eq!( match 0u8 { 2 => "Medium", _ => "Low", }, "Low" ); } }