refactor: modularize codebase and add 404 unit tests (#13)
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
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
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
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
This commit was merged in pull request #13.
This commit is contained in:
150
compliance-agent/src/pipeline/lint/ruff.rs
Normal file
150
compliance-agent/src/pipeline/lint/ruff.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
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_ruff(repo_path: &Path, repo_id: &str) -> Result<Vec<Finding>, 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<RuffResult> = 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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_ruff_output() {
|
||||
let json = r#"[
|
||||
{
|
||||
"code": "E501",
|
||||
"message": "Line too long (120 > 79 characters)",
|
||||
"filename": "src/main.py",
|
||||
"location": {"row": 42}
|
||||
},
|
||||
{
|
||||
"code": "F401",
|
||||
"message": "`os` imported but unused",
|
||||
"filename": "src/utils.py",
|
||||
"location": {"row": 1}
|
||||
}
|
||||
]"#;
|
||||
let results: Vec<RuffResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
assert_eq!(results[0].code, "E501");
|
||||
assert_eq!(results[0].filename, "src/main.py");
|
||||
assert_eq!(results[0].location.row, 42);
|
||||
|
||||
assert_eq!(results[1].code, "F401");
|
||||
assert_eq!(results[1].location.row, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ruff_empty() {
|
||||
let json = "[]";
|
||||
let results: Vec<RuffResult> = serde_json::from_str(json).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_severity_e_and_f_are_medium() {
|
||||
for code in &["E501", "E302", "F401", "F811"] {
|
||||
let is_medium = code.starts_with('E') || code.starts_with('F');
|
||||
assert!(is_medium, "Expected {code} to be Medium severity");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_severity_others_are_low() {
|
||||
for code in &["W291", "I001", "D100", "C901", "N801"] {
|
||||
let is_medium = code.starts_with('E') || code.starts_with('F');
|
||||
assert!(!is_medium, "Expected {code} to be Low severity");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ruff_with_extra_fields() {
|
||||
// Ruff output may contain additional fields we don't use
|
||||
let json = r#"[{
|
||||
"code": "W291",
|
||||
"message": "Trailing whitespace",
|
||||
"filename": "app.py",
|
||||
"location": {"row": 3, "column": 10},
|
||||
"end_location": {"row": 3, "column": 11},
|
||||
"fix": null,
|
||||
"noqa_row": 3
|
||||
}]"#;
|
||||
let results: Vec<RuffResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].code, "W291");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user