use std::path::Path; use std::time::Duration; use compliance_core::models::{Finding, ScanType, Severity}; use compliance_core::traits::{ScanOutput, Scanner}; use compliance_core::CoreError; use tokio::process::Command; use crate::pipeline::dedup; /// Timeout for each individual lint command const LINT_TIMEOUT: Duration = Duration::from_secs(120); pub struct LintScanner; impl Scanner for LintScanner { fn name(&self) -> &str { "lint" } fn scan_type(&self) -> ScanType { ScanType::Lint } #[tracing::instrument(skip_all)] async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { let mut all_findings = Vec::new(); // Detect which languages are present and run appropriate linters if has_rust_project(repo_path) { match run_clippy(repo_path, repo_id).await { Ok(findings) => all_findings.extend(findings), Err(e) => tracing::warn!("Clippy failed: {e}"), } } if has_js_project(repo_path) { match run_eslint(repo_path, repo_id).await { Ok(findings) => all_findings.extend(findings), Err(e) => tracing::warn!("ESLint failed: {e}"), } } if has_python_project(repo_path) { match run_ruff(repo_path, repo_id).await { Ok(findings) => all_findings.extend(findings), Err(e) => tracing::warn!("Ruff failed: {e}"), } } Ok(ScanOutput { findings: all_findings, sbom_entries: Vec::new(), }) } } fn has_rust_project(repo_path: &Path) -> bool { repo_path.join("Cargo.toml").exists() } fn has_js_project(repo_path: &Path) -> bool { // Only run if eslint is actually installed in the project repo_path.join("package.json").exists() && repo_path.join("node_modules/.bin/eslint").exists() } fn has_python_project(repo_path: &Path) -> bool { repo_path.join("pyproject.toml").exists() || repo_path.join("setup.py").exists() || repo_path.join("requirements.txt").exists() } /// Run a command with a timeout, returning its output or an error async fn run_with_timeout( child: tokio::process::Child, scanner_name: &str, ) -> Result { let result = tokio::time::timeout(LINT_TIMEOUT, child.wait_with_output()).await; match result { Ok(Ok(output)) => Ok(output), Ok(Err(e)) => Err(CoreError::Scanner { scanner: scanner_name.to_string(), source: Box::new(e), }), Err(_) => { // Process is dropped here which sends SIGKILL on Unix Err(CoreError::Scanner { scanner: scanner_name.to_string(), source: Box::new(std::io::Error::new( std::io::ErrorKind::TimedOut, format!("{scanner_name} timed out after {}s", LINT_TIMEOUT.as_secs()), )), }) } } } // ── Clippy ────────────────────────────────────────────── async fn run_clippy(repo_path: &Path, repo_id: &str) -> Result, CoreError> { let child = Command::new("cargo") .args([ "clippy", "--message-format=json", "--quiet", "--", "-W", "clippy::all", ]) .current_dir(repo_path) .env("RUSTC_WRAPPER", "") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| CoreError::Scanner { scanner: "clippy".to_string(), source: Box::new(e), })?; let output = run_with_timeout(child, "clippy").await?; let stdout = String::from_utf8_lossy(&output.stdout); let mut findings = Vec::new(); for line in stdout.lines() { let msg: serde_json::Value = match serde_json::from_str(line) { Ok(v) => v, Err(_) => continue, }; if msg.get("reason").and_then(|v| v.as_str()) != Some("compiler-message") { continue; } let message = match msg.get("message") { Some(m) => m, None => continue, }; let level = message.get("level").and_then(|v| v.as_str()).unwrap_or(""); if level != "warning" && level != "error" { continue; } let text = message .get("message") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let code = message .get("code") .and_then(|v| v.get("code")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if text.starts_with("aborting due to") || code.is_empty() { continue; } let (file_path, line_number) = extract_primary_span(message); let severity = if level == "error" { Severity::High } else { Severity::Low }; let fingerprint = dedup::compute_fingerprint(&[ repo_id, "clippy", &code, &file_path, &line_number.to_string(), ]); let mut finding = Finding::new( repo_id.to_string(), fingerprint, "clippy".to_string(), ScanType::Lint, format!("[clippy] {text}"), text, severity, ); finding.rule_id = Some(code); if !file_path.is_empty() { finding.file_path = Some(file_path); } if line_number > 0 { finding.line_number = Some(line_number); } findings.push(finding); } Ok(findings) } fn extract_primary_span(message: &serde_json::Value) -> (String, u32) { let spans = match message.get("spans").and_then(|v| v.as_array()) { Some(s) => s, None => return (String::new(), 0), }; for span in spans { if span.get("is_primary").and_then(|v| v.as_bool()) == Some(true) { let file = span .get("file_name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let line = span.get("line_start").and_then(|v| v.as_u64()).unwrap_or(0) as u32; return (file, line); } } (String::new(), 0) } // ── ESLint ────────────────────────────────────────────── 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, } // ── Ruff ──────────────────────────────────────────────── async fn run_ruff(repo_path: &Path, repo_id: &str) -> Result, CoreError> { let child = Command::new("ruff") .args(["check", ".", "--output-format", "json", "--exit-zero"]) .current_dir(repo_path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| CoreError::Scanner { scanner: "ruff".to_string(), source: Box::new(e), })?; let output = run_with_timeout(child, "ruff").await?; if output.stdout.is_empty() { return Ok(Vec::new()); } let results: Vec = serde_json::from_slice(&output.stdout).unwrap_or_default(); let findings = results .into_iter() .map(|r| { let severity = if r.code.starts_with('E') || r.code.starts_with('F') { Severity::Medium } else { Severity::Low }; let fingerprint = dedup::compute_fingerprint(&[ repo_id, "ruff", &r.code, &r.filename, &r.location.row.to_string(), ]); let mut finding = Finding::new( repo_id.to_string(), fingerprint, "ruff".to_string(), ScanType::Lint, format!("[ruff] {}: {}", r.code, r.message), r.message, severity, ); finding.rule_id = Some(r.code); finding.file_path = Some(r.filename); finding.line_number = Some(r.location.row); finding }) .collect(); Ok(findings) } #[derive(serde::Deserialize)] struct RuffResult { code: String, message: String, filename: String, location: RuffLocation, } #[derive(serde::Deserialize)] struct RuffLocation { row: u32, }