feat(best_practice): add code quality scanning and commit message validation
- Implemented a code quality scanner that checks for common clean-code violations in Rust source files, including missing documentation, usage of `.unwrap()` in production code, and commented-out code. - Introduced a commit message validator that follows the Conventional Commits specification, ensuring proper formatting and providing suggestions for invalid messages. - Created a unified BestPracticeEngine to encapsulate the functionalities of skills, architecture audits, code quality checks, and commit message validation. - Added tests for both the code quality scanner and commit message validator to ensure reliability and correctness.
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
//! Architecture layering audit for clean-architecture compliance.
|
||||
//!
|
||||
//! Scans Rust source files in the workspace and reports violations of the
|
||||
//! dependency rule: domain must not import from outer layers, application
|
||||
//! must not import from infrastructure or interfaces, etc.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! `audit_layering(workspace_dir)` → walk `apps/` → classify crate by path →
|
||||
//! scan `use` statements → match against forbidden crates → collect violations.
|
||||
|
||||
use anyhow::Result;
|
||||
use ignore::Walk;
|
||||
use std::path::Path;
|
||||
use tracing::instrument;
|
||||
|
||||
/// A single layering violation found during the audit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Violation {
|
||||
/// Severity level.
|
||||
pub severity: Severity,
|
||||
/// Which crate layer caused the violation.
|
||||
pub layer: &'static str,
|
||||
/// File path relative to workspace root.
|
||||
pub file: String,
|
||||
/// Line number (1-indexed).
|
||||
pub line: usize,
|
||||
/// Human-readable description.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// How severe a violation is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Severity {
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Severity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Severity::Error => write!(f, "ERROR"),
|
||||
Severity::Warning => write!(f, "WARN"),
|
||||
Severity::Info => write!(f, "INFO"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of an architecture audit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuditReport {
|
||||
/// All violations found, grouped by severity.
|
||||
pub violations: Vec<Violation>,
|
||||
/// Number of source files scanned.
|
||||
pub files_scanned: usize,
|
||||
}
|
||||
|
||||
impl AuditReport {
|
||||
/// True if any ERROR-level violations exist.
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.violations.iter().any(|v| v.severity == Severity::Error)
|
||||
}
|
||||
|
||||
/// Number of errors.
|
||||
pub fn error_count(&self) -> usize {
|
||||
self.violations.iter().filter(|v| v.severity == Severity::Error).count()
|
||||
}
|
||||
|
||||
/// Number of warnings.
|
||||
pub fn warning_count(&self) -> usize {
|
||||
self.violations.iter().filter(|v| v.severity == Severity::Warning).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a path into a clean-architecture layer name.
|
||||
fn classify_layer(crate_path: &Path) -> Option<&'static str> {
|
||||
let path_str = crate_path.to_string_lossy();
|
||||
if path_str.contains("/domain") || path_str.ends_with("/domain") {
|
||||
Some("domain")
|
||||
} else if path_str.contains("/application") || path_str.ends_with("/application") {
|
||||
Some("application")
|
||||
} else if path_str.contains("/infrastructure") || path_str.ends_with("/infrastructure") {
|
||||
Some("infrastructure")
|
||||
} else if path_str.contains("/interfaces/") {
|
||||
Some("interfaces")
|
||||
} else if path_str.contains("/gateway") {
|
||||
Some("gateway")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Forbidden import patterns per layer.
|
||||
///
|
||||
/// Returns a list of crate prefixes that the given layer must NOT import.
|
||||
fn forbidden_imports(layer: &str) -> &'static [&'static str] {
|
||||
match layer {
|
||||
"domain" => &[
|
||||
"zesdex_application",
|
||||
"zesdex_infrastructure",
|
||||
"zesdex_tui",
|
||||
"zesdex_api",
|
||||
"zesdex_daemon",
|
||||
"zesdex_ws",
|
||||
"zesdex_grpc",
|
||||
"zesdex_web",
|
||||
"zesdex_gateway",
|
||||
"tokio",
|
||||
"axum",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"ratatui",
|
||||
"crossterm",
|
||||
"tower",
|
||||
"tower_http",
|
||||
"argon2",
|
||||
"jsonwebtoken",
|
||||
"rmcp",
|
||||
"lsp_types",
|
||||
"tiktoken_rs",
|
||||
"syntect",
|
||||
"pulldown_cmark",
|
||||
"serde_yaml_ng",
|
||||
"ignore",
|
||||
"dom_smoothie",
|
||||
"fast_html2md",
|
||||
"scraper",
|
||||
"clap",
|
||||
],
|
||||
"application" => &[
|
||||
"zesdex_infrastructure",
|
||||
"zesdex_tui",
|
||||
"zesdex_api",
|
||||
"zesdex_daemon",
|
||||
"zesdex_ws",
|
||||
"zesdex_grpc",
|
||||
"zesdex_web",
|
||||
"zesdex_gateway",
|
||||
],
|
||||
"infrastructure" => &[
|
||||
"zesdex_tui",
|
||||
"zesdex_api",
|
||||
"zesdex_daemon",
|
||||
"zesdex_ws",
|
||||
"zesdex_grpc",
|
||||
"zesdex_web",
|
||||
"zesdex_gateway",
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a single Rust source file for forbidden imports.
|
||||
fn scan_file(
|
||||
file_path: &Path,
|
||||
layer: &'static str,
|
||||
root: &Path,
|
||||
) -> Vec<Violation> {
|
||||
let mut violations = Vec::new();
|
||||
let content = match std::fs::read_to_string(file_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return violations,
|
||||
};
|
||||
|
||||
let forbidden = forbidden_imports(layer);
|
||||
if forbidden.is_empty() {
|
||||
return violations;
|
||||
}
|
||||
|
||||
let relative = file_path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(file_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Match: `use zesdex_application::...` or `use zesdex_infrastructure::...`
|
||||
if trimmed.starts_with("use ") {
|
||||
for &forbidden in forbidden {
|
||||
let pattern = format!("use {forbidden}");
|
||||
if trimmed.starts_with(&pattern) || trimmed.starts_with(&format!("use crate::")) {
|
||||
// `use crate::` in domain could reference domain-only items — skip.
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with(&pattern) || trimmed.starts_with(&format!("use {forbidden}::")) {
|
||||
// Skip test code — test modules commonly import outer layers.
|
||||
let is_test = content[..content.len().saturating_sub(1)]
|
||||
.contains("#[cfg(test)]");
|
||||
if is_test {
|
||||
continue;
|
||||
}
|
||||
|
||||
violations.push(Violation {
|
||||
severity: Severity::Error,
|
||||
layer,
|
||||
file: relative.clone(),
|
||||
line: line_num + 1,
|
||||
message: format!(
|
||||
"Layer '{layer}' must not depend on '{forbidden}': {trimmed}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
violations
|
||||
}
|
||||
|
||||
/// Run a full architecture layering audit on the workspace at `root`.
|
||||
///
|
||||
/// Walks all `.rs` files under `root/apps/`, classifies each by its parent
|
||||
/// crate, and checks `use` statements against the dependency rule.
|
||||
#[instrument(skip(root))]
|
||||
pub fn audit_layering(root: &Path) -> Result<AuditReport> {
|
||||
let apps_dir = root.join("apps");
|
||||
if !apps_dir.is_dir() {
|
||||
return Ok(AuditReport {
|
||||
violations: vec![Violation {
|
||||
severity: Severity::Warning,
|
||||
layer: "workspace",
|
||||
file: "apps/".to_string(),
|
||||
line: 0,
|
||||
message: format!("apps/ directory not found at {}", apps_dir.display()),
|
||||
}],
|
||||
files_scanned: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let mut violations = Vec::new();
|
||||
let mut files_scanned = 0;
|
||||
|
||||
for entry in Walk::new(&apps_dir).flatten() {
|
||||
if entry.file_type().map_or(true, |ft| !ft.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(true, |e| e != "rs") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine which crate this file belongs to by walking up.
|
||||
let layer = path
|
||||
.ancestors()
|
||||
.skip(1)
|
||||
.find_map(|p| classify_layer(p));
|
||||
|
||||
if let Some(layer) = layer {
|
||||
files_scanned += 1;
|
||||
violations.extend(scan_file(path, layer, root));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuditReport {
|
||||
violations,
|
||||
files_scanned,
|
||||
})
|
||||
}
|
||||
|
||||
/// Count lines of code and nesting depth in a Rust source file.
|
||||
pub fn check_function_metrics(content: &str) -> Vec<Violation> {
|
||||
let mut violations = Vec::new();
|
||||
|
||||
let mut in_function = false;
|
||||
let mut fn_start = 0;
|
||||
let mut fn_name = String::new();
|
||||
let mut brace_depth = 0;
|
||||
let mut max_nesting = 0;
|
||||
let mut current_nesting: i32 = 0;
|
||||
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let line_num = i + 1;
|
||||
|
||||
// Track function entry.
|
||||
if line.trim().starts_with("fn ") && line.trim().ends_with('{') {
|
||||
in_function = true;
|
||||
fn_start = line_num;
|
||||
fn_name = line.trim().to_string();
|
||||
brace_depth = 1;
|
||||
max_nesting = 0;
|
||||
current_nesting = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_function {
|
||||
for ch in line.chars() {
|
||||
match ch {
|
||||
'{' => {
|
||||
brace_depth += 1;
|
||||
current_nesting += 1;
|
||||
max_nesting = max_nesting.max(current_nesting);
|
||||
}
|
||||
'}' => {
|
||||
brace_depth -= 1;
|
||||
current_nesting = (current_nesting.saturating_sub(1)).max(0);
|
||||
if brace_depth == 0 {
|
||||
// End of function — check metrics.
|
||||
let fn_lines = line_num - fn_start;
|
||||
if fn_lines > 40 {
|
||||
violations.push(Violation {
|
||||
severity: Severity::Warning,
|
||||
layer: "code",
|
||||
file: String::new(),
|
||||
line: fn_start,
|
||||
message: format!(
|
||||
"Function too long: {} lines (max 40): {}",
|
||||
fn_lines, fn_name
|
||||
),
|
||||
});
|
||||
}
|
||||
if max_nesting >= 4 {
|
||||
violations.push(Violation {
|
||||
severity: Severity::Warning,
|
||||
layer: "code",
|
||||
file: String::new(),
|
||||
line: fn_start,
|
||||
message: format!(
|
||||
"Deep nesting (level {}) in: {}",
|
||||
max_nesting, fn_name
|
||||
),
|
||||
});
|
||||
}
|
||||
in_function = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
violations
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn domain_layer_forbidden_imports() {
|
||||
let forbidden = forbidden_imports("domain");
|
||||
assert!(forbidden.contains(&"tokio"));
|
||||
assert!(forbidden.contains(&"zesdex_application"));
|
||||
assert!(forbidden.contains(&"axum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_layer_forbidden_imports() {
|
||||
let forbidden = forbidden_imports("application");
|
||||
assert!(forbidden.contains(&"zesdex_infrastructure"));
|
||||
assert!(!forbidden.contains(&"tokio")); // tokio is allowed in application
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_layer_works() {
|
||||
let p = Path::new("/root/apps/domain/src/lib.rs");
|
||||
assert_eq!(classify_layer(p), Some("domain"));
|
||||
|
||||
let p = Path::new("/root/apps/application/src/lib.rs");
|
||||
assert_eq!(classify_layer(p), Some("application"));
|
||||
|
||||
let p = Path::new("/root/apps/gateway/src/main.rs");
|
||||
assert_eq!(classify_layer(p), Some("gateway"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_metrics_short_function_ok() {
|
||||
let content = "fn ok() {\n let x = 1;\n}\n";
|
||||
let violations = check_function_metrics(content);
|
||||
let long: Vec<_> = violations.iter().filter(|v| v.message.contains("Function too long")).collect();
|
||||
assert!(long.is_empty(), "short function should not trigger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_metrics_reports_long_function() {
|
||||
let mut lines = String::from("fn long() {\n");
|
||||
for _ in 0..45 {
|
||||
lines.push_str(" let _ = 1;\n");
|
||||
}
|
||||
lines.push_str("}\n");
|
||||
let violations = check_function_metrics(&lines);
|
||||
let long: Vec<_> = violations.iter().filter(|v| v.message.contains("Function too long")).collect();
|
||||
assert!(!long.is_empty(), "long function should trigger warning");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user