All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Detect Changes (push) Successful in 5s
CI / Tests (push) Successful in 5m15s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
184 lines
5.2 KiB
Rust
184 lines
5.2 KiB
Rust
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<Vec<Finding>, 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<EslintFileResult> = 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<EslintMessage>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct EslintMessage {
|
|
#[serde(rename = "ruleId")]
|
|
rule_id: Option<String>,
|
|
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<EslintFileResult> = 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<EslintFileResult> = 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<EslintFileResult> = serde_json::from_str(json).unwrap();
|
|
assert_eq!(results[0].messages.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_eslint_empty_array() {
|
|
let json = "[]";
|
|
let results: Vec<EslintFileResult> = 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"
|
|
);
|
|
}
|
|
}
|