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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Code-quality analysis for clean-code compliance.
|
||||
//!
|
||||
//! Scans Rust source files for common clean-code violations:
|
||||
//! - Missing doc comments on `pub` items
|
||||
//! - `.unwrap()` / `.expect()` in production (non-test) code
|
||||
//! - `#[allow(...)]` / `#[expect(...)]` compiler bypasses
|
||||
//! - Magic number literals (integer/float constants)
|
||||
//! - Commented-out code blocks
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! `scan_file(path, root)` → read source → classify lines into scopes →
|
||||
//! match each rule → return `Vec<Finding>`.
|
||||
|
||||
use anyhow::Result;
|
||||
use ignore::Walk;
|
||||
use std::path::Path;
|
||||
use tracing::instrument;
|
||||
|
||||
/// A single code-quality finding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Finding {
|
||||
pub severity: super::arch_audit::Severity,
|
||||
pub rule: &'static str,
|
||||
pub file: String,
|
||||
pub line: usize,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Result of a code-quality scan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodeQualityReport {
|
||||
pub findings: Vec<Finding>,
|
||||
pub files_scanned: usize,
|
||||
}
|
||||
|
||||
impl CodeQualityReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.findings.iter().any(|f| f.severity == super::arch_audit::Severity::Error)
|
||||
}
|
||||
pub fn count_by_rule(&self) -> Vec<(&'static str, usize)> {
|
||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
for f in &self.findings {
|
||||
*counts.entry(f.rule).or_default() += 1;
|
||||
}
|
||||
let mut sorted: Vec<_> = counts.into_iter().collect();
|
||||
sorted.sort_by_key(|(_, c)| *c);
|
||||
sorted.reverse();
|
||||
sorted
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line is inside a `#[cfg(test)]` block.
|
||||
fn is_in_test_block(content: &[&str], line_idx: usize) -> bool {
|
||||
let mut depth = 0i32;
|
||||
let mut in_test = false;
|
||||
let mut entered_scope = false;
|
||||
for (i, line) in content.iter().enumerate() {
|
||||
if i > line_idx {
|
||||
break;
|
||||
}
|
||||
if line.contains("#[cfg(test)]") {
|
||||
in_test = true;
|
||||
}
|
||||
depth += line.matches('{').count() as i32;
|
||||
if in_test && depth > 0 {
|
||||
entered_scope = true;
|
||||
}
|
||||
depth -= line.matches('}').count() as i32;
|
||||
if in_test && entered_scope && depth == 0 && i < line_idx {
|
||||
in_test = false;
|
||||
entered_scope = false;
|
||||
}
|
||||
}
|
||||
in_test
|
||||
}
|
||||
|
||||
/// Scan a single Rust file for code-quality violations.
|
||||
pub fn scan_quality_file(file_path: &Path, root: &Path) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
let content = match std::fs::read_to_string(file_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return findings,
|
||||
};
|
||||
|
||||
let relative = file_path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(file_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
// Track pub items for doc-comment checking.
|
||||
let mut prev_line_doc = false;
|
||||
let mut prev_line_empty = false;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
let line_num = i + 1;
|
||||
let in_test = is_in_test_block(&lines, i);
|
||||
|
||||
// ── Rule: Compiler bypass ──────────────────────────────────────
|
||||
if trimmed.starts_with("#[allow(") || trimmed.starts_with("#[expect(") {
|
||||
// Skip if this is in the workspace lints config (Cargo.toml)
|
||||
if trimmed.contains("clippy::") || trimmed.contains("dead_code") {
|
||||
findings.push(Finding {
|
||||
severity: super::arch_audit::Severity::Error,
|
||||
rule: "compiler-bypass",
|
||||
file: relative.clone(),
|
||||
line: line_num,
|
||||
message: format!("Compiler bypass attribute found: {trimmed}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rule: unwrap / expect in production code ───────────────────
|
||||
if !in_test {
|
||||
if let Some(col) = trimmed.find(".unwrap(") {
|
||||
// Allow unwrap in test code and in `#[]` attributes
|
||||
if !trimmed.starts_with("//") {
|
||||
let snippet = &trimmed[col..(col + 20).min(trimmed.len())];
|
||||
findings.push(Finding {
|
||||
severity: super::arch_audit::Severity::Warning,
|
||||
rule: "unwrap-in-production",
|
||||
file: relative.clone(),
|
||||
line: line_num,
|
||||
message: format!(
|
||||
"Use `?` or proper error handling instead of `.unwrap()`: {snippet}..."
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(col) = trimmed.find(".expect(") {
|
||||
if !trimmed.starts_with("//") {
|
||||
let snippet = &trimmed[col..(col + 20).min(trimmed.len())];
|
||||
findings.push(Finding {
|
||||
severity: super::arch_audit::Severity::Info,
|
||||
rule: "expect-in-production",
|
||||
file: relative.clone(),
|
||||
line: line_num,
|
||||
message: format!(".expect() with message: {snippet}..."),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rule: Missing doc comments on pub items ────────────────────
|
||||
if trimmed.starts_with("pub ") || trimmed.starts_with("pub(") {
|
||||
if !prev_line_doc && !prev_line_empty {
|
||||
// Check it's a struct/enum/fn/trait/type/const/mod
|
||||
let is_item = trimmed.starts_with("pub fn ")
|
||||
|| trimmed.starts_with("pub struct ")
|
||||
|| trimmed.starts_with("pub enum ")
|
||||
|| trimmed.starts_with("pub trait ")
|
||||
|| trimmed.starts_with("pub type ")
|
||||
|| trimmed.starts_with("pub const ")
|
||||
|| trimmed.starts_with("pub mod ")
|
||||
|| trimmed.starts_with("pub(crate) fn ")
|
||||
|| trimmed.starts_with("pub(crate) struct ")
|
||||
|| trimmed.starts_with("pub(crate) enum ")
|
||||
|| trimmed.starts_with("pub(crate) trait ");
|
||||
if is_item {
|
||||
findings.push(Finding {
|
||||
severity: super::arch_audit::Severity::Info,
|
||||
rule: "missing-doc",
|
||||
file: relative.clone(),
|
||||
line: line_num,
|
||||
message: format!("Missing doc comment on pub item: {trimmed}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rule: Commented-out code ───────────────────────────────────
|
||||
if trimmed.starts_with("// ") && !in_test {
|
||||
let stripped = trimmed.trim_start_matches("// ");
|
||||
if stripped.starts_with("fn ")
|
||||
|| stripped.starts_with("let ")
|
||||
|| stripped.starts_with("if ")
|
||||
|| stripped.starts_with("for ")
|
||||
|| stripped.starts_with("while ")
|
||||
|| stripped.starts_with("match ")
|
||||
|| stripped.starts_with("pub ")
|
||||
|| stripped.starts_with("impl ")
|
||||
{
|
||||
findings.push(Finding {
|
||||
severity: super::arch_audit::Severity::Info,
|
||||
rule: "commented-code",
|
||||
file: relative.clone(),
|
||||
line: line_num,
|
||||
message: format!("Commented-out code detected: {trimmed}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Track doc-comment state
|
||||
prev_line_doc = trimmed.starts_with("///") || trimmed.starts_with("//!");
|
||||
prev_line_empty = trimmed.is_empty() || trimmed == "//";
|
||||
}
|
||||
|
||||
findings
|
||||
}
|
||||
|
||||
/// Scan an entire workspace for code-quality violations.
|
||||
#[instrument(skip(root))]
|
||||
pub fn scan_quality(root: &Path) -> Result<CodeQualityReport> {
|
||||
let apps_dir = root.join("apps");
|
||||
if !apps_dir.is_dir() {
|
||||
return Ok(CodeQualityReport {
|
||||
findings: vec![],
|
||||
files_scanned: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let mut findings = 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;
|
||||
}
|
||||
files_scanned += 1;
|
||||
findings.extend(scan_quality_file(path, root));
|
||||
}
|
||||
|
||||
Ok(CodeQualityReport {
|
||||
findings,
|
||||
files_scanned,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_unwrap_in_production_code() {
|
||||
let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("test.rs");
|
||||
std::fs::write(&file, "fn x() { let y = foo.unwrap(); }\n").unwrap();
|
||||
|
||||
let findings = scan_quality_file(&file, &dir);
|
||||
let unwrap_findings: Vec<_> = findings.iter().filter(|f| f.rule == "unwrap-in-production").collect();
|
||||
assert!(!unwrap_findings.is_empty(), "should detect unwrap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unwrap_in_test_block() {
|
||||
let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("test.rs");
|
||||
std::fs::write(
|
||||
&file,
|
||||
"#[cfg(test)]\nmod tests {\n fn x() { let y = foo.unwrap(); }\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let findings = scan_quality_file(&file, &dir);
|
||||
let unwrap_findings: Vec<_> = findings.iter().filter(|f| f.rule == "unwrap-in-production").collect();
|
||||
assert!(unwrap_findings.is_empty(), "should skip unwrap in test blocks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_allow_attributes() {
|
||||
let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("test.rs");
|
||||
std::fs::write(&file, "#[allow(clippy::too_many_arguments)]\nfn x() {}\n").unwrap();
|
||||
|
||||
let findings = scan_quality_file(&file, &dir);
|
||||
let bypass_findings: Vec<_> = findings.iter().filter(|f| f.rule == "compiler-bypass").collect();
|
||||
assert!(!bypass_findings.is_empty(), "should detect allow attributes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Commit message validation following Conventional Commits (Bahasa Indonesia).
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! `validate_commit_message(msg)` → parse subject → check type, scope,
|
||||
//! description format → return list of errors (empty = valid).
|
||||
//!
|
||||
//! # Format
|
||||
//!
|
||||
//! ```text
|
||||
//! feat(scope): description
|
||||
//! fix(scope): description
|
||||
//! chore: description
|
||||
//! docs: description
|
||||
//! refactor: description
|
||||
//! test: description
|
||||
//! style: description
|
||||
//! perf: description
|
||||
//! ci: description
|
||||
//! ```
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// Valid commit types.
|
||||
const VALID_TYPES: &[&str] = &[
|
||||
"feat", "fix", "chore", "docs", "refactor", "test", "style", "perf", "ci", "build", "revert",
|
||||
];
|
||||
|
||||
/// Validate a commit message against the Conventional Commits spec.
|
||||
///
|
||||
/// Returns `Ok(())` if valid, or `Err(errors)` with human-readable messages.
|
||||
pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
let msg = msg.trim();
|
||||
|
||||
if msg.is_empty() {
|
||||
errors.push("Commit message must not be empty.".to_string());
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
// Split subject and body.
|
||||
let subject = msg.lines().next().unwrap_or(msg);
|
||||
|
||||
// Check subject length.
|
||||
if subject.len() > 72 {
|
||||
errors.push(format!(
|
||||
"Subject line is {} characters (max 72).",
|
||||
subject.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Parse: `type(scope): description` or `type!: description` or `type: description`
|
||||
let re = Regex::new(
|
||||
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$",
|
||||
)
|
||||
.expect("valid regex for commit parsing");
|
||||
|
||||
match re.captures(subject) {
|
||||
None => {
|
||||
errors.push(format!(
|
||||
"Subject does not match Conventional Commits format.\n\
|
||||
Expected: `type(scope): description`\n\
|
||||
Got: {subject}\n\
|
||||
Valid types: {}",
|
||||
VALID_TYPES.join(", ")
|
||||
));
|
||||
}
|
||||
Some(caps) => {
|
||||
let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or("");
|
||||
let scope = caps.name("scope").map(|m| m.as_str());
|
||||
let desc = caps.name("desc").map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
// Validate type.
|
||||
if !VALID_TYPES.contains(&type_) {
|
||||
errors.push(format!(
|
||||
"Invalid commit type '{type_}'. Valid types: {}",
|
||||
VALID_TYPES.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
// Scope is lowercase, no spaces.
|
||||
if let Some(s) = scope {
|
||||
if s.contains(' ') {
|
||||
errors.push(format!("Scope must not contain spaces: '{s}'"));
|
||||
}
|
||||
if s.chars().any(|c| c.is_uppercase()) {
|
||||
errors.push(format!("Scope must be lowercase: '{s}'"));
|
||||
}
|
||||
}
|
||||
|
||||
// Description rules.
|
||||
if desc.is_empty() {
|
||||
errors.push("Description must not be empty.".to_string());
|
||||
} else {
|
||||
let first_char = desc.chars().next().unwrap_or(' ');
|
||||
if first_char.is_uppercase() {
|
||||
errors.push(format!(
|
||||
"Description must start with lowercase: '{desc}'"
|
||||
));
|
||||
}
|
||||
if desc.ends_with('.') {
|
||||
errors.push(format!(
|
||||
"Description must not end with a period: '{desc}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific rules.
|
||||
match type_ {
|
||||
"chore" | "docs" | "refactor" | "test" | "style" | "perf" | "ci" | "build"
|
||||
| "revert" => {
|
||||
if scope.is_some() {
|
||||
errors.push(format!(
|
||||
"'{type_}' commits should not use a scope. \
|
||||
Only 'feat' and 'fix' require scopes."
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a commit message to a structured representation.
|
||||
pub struct CommitInfo {
|
||||
pub type_: String,
|
||||
pub scope: Option<String>,
|
||||
pub breaking: bool,
|
||||
pub description: String,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a commit message into its structured components.
|
||||
pub fn parse_commit_message(msg: &str) -> Option<CommitInfo> {
|
||||
let msg = msg.trim();
|
||||
let subject = msg.lines().next()?;
|
||||
|
||||
let re = Regex::new(
|
||||
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$",
|
||||
)
|
||||
.expect("valid regex");
|
||||
|
||||
let caps = re.captures(subject)?;
|
||||
|
||||
let body_lines: Vec<&str> = msg.lines().skip(1).collect();
|
||||
let body = if body_lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(body_lines.join("\n"))
|
||||
};
|
||||
|
||||
Some(CommitInfo {
|
||||
type_: caps.name("type").map(|m| m.as_str()).unwrap_or("").to_string(),
|
||||
scope: caps.name("scope").map(|m| m.as_str().to_string()),
|
||||
breaking: caps.name("breaking").is_some(),
|
||||
description: caps.name("desc").map(|m| m.as_str()).unwrap_or("").to_string(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// Suggest a commit message template for a given change type.
|
||||
pub fn suggest_template(type_: &str, scope: Option<&str>) -> String {
|
||||
match type_ {
|
||||
"feat" | "fix" if scope.is_some() => {
|
||||
format!("{}({}): <imperative description>", type_, scope.unwrap())
|
||||
}
|
||||
"feat" | "fix" => {
|
||||
format!("{}(<scope>): <imperative description>", type_)
|
||||
}
|
||||
_ => {
|
||||
format!("{}: <imperative description>", type_)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_feat_commit() {
|
||||
assert!(validate_commit_message("feat(tool): add batch file delete").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_fix_commit() {
|
||||
assert!(validate_commit_message("fix(ipc): reconnect loop on socket timeout").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_chore_commit() {
|
||||
assert!(validate_commit_message("chore: bump reqwest to 0.13").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_docs_commit() {
|
||||
assert!(validate_commit_message("docs: add architecture diagram to README").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_message() {
|
||||
assert!(validate_commit_message("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_colon() {
|
||||
assert!(validate_commit_message("feat:missing space").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_uppercase_description() {
|
||||
let result = validate_commit_message("feat(tool): Add new feature");
|
||||
assert!(result.is_err(), "should reject uppercase start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_trailing_period() {
|
||||
let result = validate_commit_message("feat(tool): add new feature.");
|
||||
assert!(result.is_err(), "should reject trailing period");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_type() {
|
||||
let result = validate_commit_message("wat(tool): something broke");
|
||||
assert!(result.is_err(), "should reject invalid type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_valid_commit() {
|
||||
let parsed = parse_commit_message("feat(agent): add parallel execution\n\nWith cycle orchestration.").unwrap();
|
||||
assert_eq!(parsed.type_, "feat");
|
||||
assert_eq!(parsed.scope, Some("agent".to_string()));
|
||||
assert!(!parsed.breaking);
|
||||
assert_eq!(parsed.description, "add parallel execution");
|
||||
assert!(parsed.body.unwrap().contains("cycle orchestration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_breaking_change() {
|
||||
let parsed = parse_commit_message("feat(api)!: change response format").unwrap();
|
||||
assert!(parsed.breaking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggests_template() {
|
||||
let tpl = suggest_template("feat", Some("tool"));
|
||||
assert_eq!(tpl, "feat(tool): <imperative description>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! # Built-in Kana Engineering Best Practices Engine
|
||||
//!
|
||||
//! This module provides compile-time embedded skills, architecture audit,
|
||||
//! code-quality scanning, and commit-message validation as **built-in**
|
||||
//! features of the zesdex binary — not external configuration files.
|
||||
//!
|
||||
//! ## Modules
|
||||
//!
|
||||
//! | Module | Purpose |
|
||||
//! |--------|---------|
|
||||
//! | [`skills`] | Compile-time embedded skill markdown files via `include_dir!` |
|
||||
//! | [`arch_audit`] | Clean-architecture layering violation scanner |
|
||||
//! | [`code_quality`] | Clean-code rule checker (unwrap, missing docs, etc.) |
|
||||
//! | [`commit`] | Conventional Commits message validator |
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! The [`BestPracticeEngine`] struct provides a unified API that tools call.
|
||||
//! New tool implementations in `crate::tools::best_practice` delegate to this
|
||||
//! engine.
|
||||
|
||||
pub mod arch_audit;
|
||||
pub mod code_quality;
|
||||
pub mod commit;
|
||||
pub mod skills;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
/// Unified engine for all best-practice operations.
|
||||
///
|
||||
/// Wraps the individual audit, quality, and commit modules behind a single
|
||||
/// struct that tools and subagents can call without knowing the module layout.
|
||||
#[derive(Default)]
|
||||
pub struct BestPracticeEngine {
|
||||
/// Compile-time embedded skills, lazy-loaded.
|
||||
embedded_skills: std::sync::OnceLock<skills::EmbeddedSkills>,
|
||||
}
|
||||
|
||||
impl BestPracticeEngine {
|
||||
/// Create a new engine (skills index is built lazily on first access).
|
||||
pub fn new() -> Self {
|
||||
BestPracticeEngine {
|
||||
embedded_skills: std::sync::OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skills ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Access the embedded skills index.
|
||||
pub fn skills(&self) -> &skills::EmbeddedSkills {
|
||||
self.embedded_skills
|
||||
.get_or_init(skills::EmbeddedSkills::load)
|
||||
}
|
||||
|
||||
/// List all available embedded-skills names.
|
||||
pub fn list_skills(&self) -> Vec<&'static str> {
|
||||
self.skills().list()
|
||||
}
|
||||
|
||||
/// Get the full content of a skill by name.
|
||||
pub fn get_skill(&self, name: &str) -> Option<&'static str> {
|
||||
self.skills().get(name)
|
||||
}
|
||||
|
||||
/// Get skill summaries (name + description).
|
||||
pub fn skill_summaries(&self) -> Vec<(&'static str, &'static str)> {
|
||||
self.skills().summaries()
|
||||
}
|
||||
|
||||
// ── Architecture Audit ─────────────────────────────────────────────
|
||||
|
||||
/// Run a full architecture layering audit on a workspace directory.
|
||||
pub fn audit_layering(&self, workspace_root: &Path) -> Result<arch_audit::AuditReport> {
|
||||
arch_audit::audit_layering(workspace_root)
|
||||
}
|
||||
|
||||
// ── Code Quality ───────────────────────────────────────────────────
|
||||
|
||||
/// Run a full code-quality scan on a workspace directory.
|
||||
pub fn scan_quality(&self, workspace_root: &Path) -> Result<code_quality::CodeQualityReport> {
|
||||
code_quality::scan_quality(workspace_root)
|
||||
}
|
||||
|
||||
// ── Commit Convention ──────────────────────────────────────────────
|
||||
|
||||
/// Validate a commit message against Conventional Commits.
|
||||
pub fn validate_commit(&self, message: &str) -> Result<(), Vec<String>> {
|
||||
commit::validate_commit_message(message)
|
||||
}
|
||||
|
||||
/// Parse a commit message into its structured fields.
|
||||
pub fn parse_commit(&self, message: &str) -> Option<commit::CommitInfo> {
|
||||
commit::parse_commit_message(message)
|
||||
}
|
||||
|
||||
/// Get a commit template suggestion.
|
||||
pub fn suggest_commit_template(&self, type_: &str, scope: Option<&str>) -> String {
|
||||
commit::suggest_template(type_, scope)
|
||||
}
|
||||
|
||||
// ── Combined Audit ─────────────────────────────────────────────────
|
||||
|
||||
/// Run all audits (layering + code quality) and return combined results.
|
||||
pub fn audit_all(&self, workspace_root: &Path) -> Result<CombinedAuditReport> {
|
||||
let layering = self.audit_layering(workspace_root)?;
|
||||
let quality = self.scan_quality(workspace_root)?;
|
||||
|
||||
Ok(CombinedAuditReport {
|
||||
layering,
|
||||
quality,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined report from all audits.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CombinedAuditReport {
|
||||
pub layering: arch_audit::AuditReport,
|
||||
pub quality: code_quality::CodeQualityReport,
|
||||
}
|
||||
|
||||
impl CombinedAuditReport {
|
||||
/// Format the full report as a human-readable string.
|
||||
pub fn format(&self) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
// ── Layering ──
|
||||
out.push_str(&format!(
|
||||
"=== Architecture Layering Audit ===\n\
|
||||
Files scanned: {}\n",
|
||||
self.layering.files_scanned
|
||||
));
|
||||
if self.layering.violations.is_empty() {
|
||||
out.push_str(" ✅ No layering violations found.\n");
|
||||
} else {
|
||||
for v in &self.layering.violations {
|
||||
out.push_str(&format!(
|
||||
" [{}] {}:{} — {}\n",
|
||||
v.severity, v.file, v.line, v.message
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Code Quality ──
|
||||
out.push_str(&format!(
|
||||
"\n=== Code Quality Scan ===\n\
|
||||
Files scanned: {}\n",
|
||||
self.quality.files_scanned
|
||||
));
|
||||
if self.quality.findings.is_empty() {
|
||||
out.push_str(" ✅ No code-quality issues found.\n");
|
||||
} else {
|
||||
let by_rule = self.quality.count_by_rule();
|
||||
out.push_str(" By rule:\n");
|
||||
for (rule, count) in &by_rule {
|
||||
out.push_str(&format!(" {rule}: {count}\n"));
|
||||
}
|
||||
out.push_str(" Top findings:\n");
|
||||
for f in self.quality.findings.iter().take(20) {
|
||||
out.push_str(&format!(
|
||||
" [{}] {}:{} — {}: {}\n",
|
||||
f.severity, f.file, f.line, f.rule, f.message
|
||||
));
|
||||
}
|
||||
if self.quality.findings.len() > 20 {
|
||||
out.push_str(&format!(
|
||||
" ... and {} more findings.\n",
|
||||
self.quality.findings.len() - 20
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn engine_lists_skills() {
|
||||
let engine = BestPracticeEngine::new();
|
||||
let names = engine.list_skills();
|
||||
assert!(names.contains(&"clean-code"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Embedding Kana Engineering best-practice skills as compiled-in static assets.
|
||||
//!
|
||||
//! Skill markdown files from `apps/infrastructure/skills/` are embedded at
|
||||
//! compile time via `include_dir!` and served through [`EmbeddedSkills`].
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! `EmbeddedSkills::load()` reads the compile-time directory tree → indexes
|
||||
//! every `SKILL.md` by its parent directory name → exposes lookup / listing.
|
||||
|
||||
use include_dir::{include_dir, Dir, DirEntry};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The compile-time-embedded skills directory tree.
|
||||
static SKILLS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills");
|
||||
|
||||
/// Index of embedded best-practice skills, built once at startup.
|
||||
///
|
||||
/// Each skill is identified by its directory name (e.g. `"clean-code"`) and
|
||||
/// its content is the full text of `SKILL.md` within that directory.
|
||||
pub struct EmbeddedSkills {
|
||||
skills: HashMap<&'static str, &'static str>,
|
||||
}
|
||||
|
||||
impl EmbeddedSkills {
|
||||
/// Walk the embedded directory tree and build the skill index.
|
||||
///
|
||||
/// Flow: iterate `Dir::entries()` → find every `SKILL.md` file →
|
||||
/// store `(parent_dir_name, file_contents)` in the map.
|
||||
pub fn load() -> Self {
|
||||
let mut skills: HashMap<&str, &str> = HashMap::new();
|
||||
|
||||
fn walk<'a>(dir: &Dir<'a>, skills: &mut HashMap<&'a str, &'a str>) {
|
||||
for entry in dir.entries() {
|
||||
match entry {
|
||||
DirEntry::Dir(sub) => walk(sub, skills),
|
||||
DirEntry::File(file) => {
|
||||
if file.path().file_name().map_or(false, |n| n == "SKILL.md") {
|
||||
if let Some(parent) = file
|
||||
.path()
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
{
|
||||
if let Some(content) = file.contents_utf8() {
|
||||
skills.entry(parent).or_insert(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(&SKILLS_DIR, &mut skills);
|
||||
EmbeddedSkills { skills }
|
||||
}
|
||||
|
||||
/// Return the full `SKILL.md` content for a named skill, if present.
|
||||
pub fn get(&self, name: &str) -> Option<&'static str> {
|
||||
self.skills.get(name).copied()
|
||||
}
|
||||
|
||||
/// List all available skill names.
|
||||
pub fn list(&self) -> Vec<&'static str> {
|
||||
let mut names: Vec<&str> = self.skills.keys().copied().collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Return an iterator of `(name, trimmed_description)` pairs.
|
||||
///
|
||||
/// Description is extracted from the YAML frontmatter `description:` field.
|
||||
pub fn summaries(&self) -> Vec<(&'static str, &'static str)> {
|
||||
let mut out: Vec<(&str, &str)> = Vec::new();
|
||||
for name in self.list() {
|
||||
let content = self.skills.get(name).copied().unwrap_or("");
|
||||
let desc = Self::extract_description(content).unwrap_or("");
|
||||
out.push((name, desc));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract the `description:` field from YAML frontmatter.
|
||||
fn extract_description(content: &str) -> Option<&str> {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if lines.first()? != &"---" {
|
||||
return None;
|
||||
}
|
||||
for line in &lines[1..] {
|
||||
if let Some(rest) = line.strip_prefix("description: ") {
|
||||
return Some(rest.trim());
|
||||
}
|
||||
if line == &"---" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn embedded_skills_loads_known_skills() {
|
||||
let skills = EmbeddedSkills::load();
|
||||
let names = skills.list();
|
||||
assert!(
|
||||
names.contains(&"clean-code"),
|
||||
"expected clean-code skill, got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"commit-convention"),
|
||||
"expected commit-convention skill, got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"kana-rust-backend-best-practice"),
|
||||
"expected kana-rust-backend-best-practice skill, got {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_skill_has_content() {
|
||||
let skills = EmbeddedSkills::load();
|
||||
for name in skills.list() {
|
||||
let content = skills.get(name);
|
||||
assert!(content.is_some(), "skill '{name}' has no content");
|
||||
assert!(
|
||||
content.unwrap().len() > 50,
|
||||
"skill '{name}' content suspiciously short"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_skill_returns_none() {
|
||||
let skills = EmbeddedSkills::load();
|
||||
assert!(skills.get("nonexistent-skill").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summaries_include_all_skills() {
|
||||
let skills = EmbeddedSkills::load();
|
||||
let summaries = skills.summaries();
|
||||
assert_eq!(summaries.len(), skills.list().len());
|
||||
for (name, desc) in &summaries {
|
||||
assert!(!desc.is_empty(), "skill '{name}' has empty description");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod best_practice;
|
||||
pub mod bgbash;
|
||||
pub mod guard;
|
||||
pub mod ipc;
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Built-in best-practice tools for zesdex.
|
||||
//!
|
||||
//! These tools allow the LLM agent to run architecture audits, code-quality
|
||||
//! scans, commit-message validation, and skill lookups — all as compiled-in
|
||||
//! features of the zesdex binary.
|
||||
//!
|
||||
//! # Tools
|
||||
//!
|
||||
//! | Tool name | Action |
|
||||
//! |-----------|--------|
|
||||
//! | `best_practice` | Run architecture audit, code-quality scan, or skill lookup |
|
||||
//! | `commit_convention` | Validate or suggest commit messages |
|
||||
|
||||
use crate::best_practice::BestPracticeEngine;
|
||||
use crate::tools::{arg_str, Tool, ToolCtx};
|
||||
use anyhow::{bail, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::OnceLock;
|
||||
use tracing::instrument;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared engine instance (lazy, created once)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn engine() -> &'static BestPracticeEngine {
|
||||
static ENGINE: OnceLock<BestPracticeEngine> = OnceLock::new();
|
||||
ENGINE.get_or_init(BestPracticeEngine::new)
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// best_practice
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run best-practice audits (architecture, code quality, skills).
|
||||
pub struct BestPractice;
|
||||
|
||||
impl Tool for BestPractice {
|
||||
fn name(&self) -> &'static str {
|
||||
"best_practice"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run architecture audit, code-quality scan, embedded-skills lookup, \
|
||||
or commit-message validation. Sub-actions: 'audit_all', 'audit_layering', \
|
||||
'scan_quality', 'list_skills', 'get_skill', 'validate_commit', 'suggest_commit'."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"audit_all",
|
||||
"audit_layering",
|
||||
"scan_quality",
|
||||
"list_skills",
|
||||
"get_skill",
|
||||
"validate_commit",
|
||||
"suggest_commit"
|
||||
],
|
||||
"description": "Which action to perform"
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Path to workspace root (required for audit/scan actions)"
|
||||
},
|
||||
"skill_name": {
|
||||
"type": "string",
|
||||
"description": "Skill name to retrieve (required for 'get_skill')"
|
||||
},
|
||||
"commit_message": {
|
||||
"type": "string",
|
||||
"description": "Commit message to validate (required for 'validate_commit')"
|
||||
},
|
||||
"commit_type": {
|
||||
"type": "string",
|
||||
"description": "Commit type for template suggestion (e.g. 'feat', 'fix')"
|
||||
},
|
||||
"commit_scope": {
|
||||
"type": "string",
|
||||
"description": "Optional scope for template suggestion"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, _ctx, args))]
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let action = arg_str(args, "action")?;
|
||||
let eng = engine();
|
||||
|
||||
match action.as_str() {
|
||||
"list_skills" => {
|
||||
let skills = eng.list_skills();
|
||||
let summaries = eng.skill_summaries();
|
||||
let mut out = String::from("=== Embedded Best-Practice Skills ===\n\n");
|
||||
for (name, desc) in &summaries {
|
||||
out.push_str(&format!(" {name:<40} {desc}\n"));
|
||||
}
|
||||
if skills.is_empty() {
|
||||
out.push_str(" (no skills embedded)\n");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
"get_skill" => {
|
||||
let name = arg_str(args, "skill_name")?;
|
||||
match eng.get_skill(&name) {
|
||||
Some(content) => Ok(content.to_string()),
|
||||
None => {
|
||||
let available = eng.list_skills();
|
||||
bail!(
|
||||
"Skill '{name}' not found. Available skills: {}",
|
||||
available.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"validate_commit" => {
|
||||
let msg = arg_str(args, "commit_message")?;
|
||||
match eng.validate_commit(&msg) {
|
||||
Ok(()) => Ok("✅ Commit message is valid.".to_string()),
|
||||
Err(errors) => {
|
||||
let mut out = String::from("❌ Commit message validation failed:\n");
|
||||
for err in &errors {
|
||||
out.push_str(&format!(" - {err}\n"));
|
||||
}
|
||||
// Suggest a template.
|
||||
if let Some(parsed) = eng.parse_commit(&msg) {
|
||||
let tpl = eng.suggest_commit_template(&parsed.type_, parsed.scope.as_deref());
|
||||
out.push_str(&format!("\nTemplate: {tpl}\n"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"suggest_commit" => {
|
||||
let type_ = arg_str(args, "commit_type")?;
|
||||
let scope = args.get("commit_scope").and_then(|v| v.as_str());
|
||||
let tpl = eng.suggest_commit_template(&type_, scope);
|
||||
Ok(format!(
|
||||
"Suggested commit template:\n\n {tpl}\n\n\
|
||||
Valid types: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert"
|
||||
))
|
||||
}
|
||||
|
||||
"audit_layering" | "audit_all" | "scan_quality" => {
|
||||
let workspace = match args.get("workspace").and_then(|v| v.as_str()) {
|
||||
Some(w) => w.to_string(),
|
||||
None => bail!("'workspace' argument is required for '{action}'"),
|
||||
};
|
||||
let ws_path = std::path::Path::new(&workspace);
|
||||
|
||||
// Use the host's dirs data dir when workspace is the default data directory.
|
||||
let report = match action.as_str() {
|
||||
"audit_layering" => {
|
||||
let r = eng.audit_layering(ws_path)?;
|
||||
let mut out = format!(
|
||||
"=== Architecture Layering Audit ===\n\
|
||||
Files scanned: {}\n",
|
||||
r.files_scanned
|
||||
);
|
||||
if r.violations.is_empty() {
|
||||
out.push_str(" ✅ No layering violations found.\n");
|
||||
} else {
|
||||
out.push_str(&format!(" Errors: {}\n", r.error_count()));
|
||||
out.push_str(&format!(" Warnings: {}\n", r.warning_count()));
|
||||
for v in &r.violations {
|
||||
out.push_str(&format!(
|
||||
" [{}] {}:{} — {}\n",
|
||||
v.severity, v.file, v.line, v.message
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
"scan_quality" => {
|
||||
let r = eng.scan_quality(ws_path)?;
|
||||
let mut out = format!(
|
||||
"=== Code Quality Scan ===\n\
|
||||
Files scanned: {}\n",
|
||||
r.files_scanned
|
||||
);
|
||||
if r.findings.is_empty() {
|
||||
out.push_str(" ✅ No code-quality issues found.\n");
|
||||
} else {
|
||||
let by_rule = r.count_by_rule();
|
||||
out.push_str(" By rule:\n");
|
||||
for (rule, count) in &by_rule {
|
||||
out.push_str(&format!(" {rule}: {count}\n"));
|
||||
}
|
||||
for f in r.findings.iter().take(15) {
|
||||
out.push_str(&format!(
|
||||
" [{}] {}:{} — {}: {}\n",
|
||||
f.severity, f.file, f.line, f.rule, f.message
|
||||
));
|
||||
}
|
||||
if r.findings.len() > 15 {
|
||||
out.push_str(&format!(
|
||||
" ... and {} more findings.\n",
|
||||
r.findings.len() - 15
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => {
|
||||
let combined = eng.audit_all(ws_path)?;
|
||||
combined.format()
|
||||
}
|
||||
};
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
other => bail!("Unknown action '{other}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// commit_convention
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Dedicated commit-message validation tool.
|
||||
pub struct CommitConvention;
|
||||
|
||||
impl Tool for CommitConvention {
|
||||
fn name(&self) -> &'static str {
|
||||
"commit_convention"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Validate a git commit message against Conventional Commits format (Bahasa Indonesia). \
|
||||
Checks type, scope, description casing, length, and punctuation."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The full commit message to validate"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, _ctx, args))]
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let msg = arg_str(args, "message")?;
|
||||
let eng = engine();
|
||||
|
||||
// Try to parse for additional context.
|
||||
let info = eng.parse_commit(&msg);
|
||||
|
||||
match eng.validate_commit(&msg) {
|
||||
Ok(()) => {
|
||||
let mut out = String::from("✅ Valid Conventional Commit.\n");
|
||||
if let Some(i) = info {
|
||||
out.push_str(&format!(" Type: {}\n", i.type_));
|
||||
if let Some(ref s) = i.scope {
|
||||
out.push_str(&format!(" Scope: {s}\n"));
|
||||
}
|
||||
out.push_str(&format!(" Breaking: {}\n", i.breaking));
|
||||
out.push_str(&format!(" Description: {}\n", i.description));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Err(errors) => {
|
||||
let mut out = String::from("❌ Invalid commit message:\n");
|
||||
for err in &errors {
|
||||
out.push_str(&format!(" - {err}\n"));
|
||||
}
|
||||
// Provide a template suggestion.
|
||||
out.push_str("\nExpected format:\n");
|
||||
out.push_str(" feat(scope): <imperative description>\n");
|
||||
out.push_str(" fix(scope): <imperative description>\n");
|
||||
out.push_str(" chore: <imperative description>\n");
|
||||
out.push_str(" docs: <imperative description>\n");
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_ctx() -> ToolCtx {
|
||||
// Minimal context for unit tests
|
||||
let temp = std::env::temp_dir().join("zesdex-bptest");
|
||||
let _ = std::fs::create_dir_all(&temp);
|
||||
ToolCtx::builder()
|
||||
.workspaces(vec![temp.clone()])
|
||||
.session_dir(temp.clone())
|
||||
.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_practice_list_skills() {
|
||||
let tool = BestPractice;
|
||||
let args = json!({"action": "list_skills"});
|
||||
let result = tool.run(&test_ctx(), &args).unwrap();
|
||||
assert!(result.contains("clean-code"), "should list clean-code: {result}");
|
||||
assert!(result.contains("commit-convention"), "should list commit-convention: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_practice_get_skill() {
|
||||
let tool = BestPractice;
|
||||
let args = json!({"action": "get_skill", "skill_name": "clean-code"});
|
||||
let result = tool.run(&test_ctx(), &args).unwrap();
|
||||
assert!(result.contains("Clean Code"), "should contain skill content: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_convention_valid() {
|
||||
let tool = CommitConvention;
|
||||
let args = json!({"message": "feat(tool): add best practice audit"});
|
||||
let result = tool.run(&test_ctx(), &args).unwrap();
|
||||
assert!(result.contains("✅"), "valid commit should succeed: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_convention_invalid() {
|
||||
let tool = CommitConvention;
|
||||
let args = json!({"message": "Add new feature"});
|
||||
let result = tool.run(&test_ctx(), &args).unwrap();
|
||||
assert!(result.contains("❌"), "invalid commit should fail: {result}");
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod bash_tools;
|
||||
pub mod best_practice;
|
||||
pub mod context;
|
||||
pub mod executor;
|
||||
pub mod fs;
|
||||
|
||||
@@ -45,6 +45,9 @@ pub fn all_tools() -> Vec<Box<dyn super::Tool>> {
|
||||
Box::new(super::semantic_search::SemanticSearch),
|
||||
Box::new(super::semantic_search::RebuildIndex),
|
||||
Box::new(super::parallel_delegate::ParallelDelegate),
|
||||
// ── Best-practice tools (built-in) ─────────────────────────
|
||||
Box::new(super::best_practice::BestPractice),
|
||||
Box::new(super::best_practice::CommitConvention),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user