pub mod baseline;
mod checks;
mod conventions;
mod findings;
pub mod fixer;
use std::path::Path;
pub use checks::{CheckResult, CheckStatus};
pub use conventions::{Convention, Deviation, DeviationKind, Language, Outlier};
pub use findings::{Finding, Severity};
use crate::{component, Result};
fn is_zero(v: &usize) -> bool {
*v == 0
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AuditSummary {
pub files_scanned: usize,
pub conventions_detected: usize,
#[serde(skip_serializing_if = "is_zero")]
pub outliers_found: usize,
pub alignment_score: f32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CodeAuditResult {
pub component_id: String,
pub source_path: String,
pub summary: AuditSummary,
pub conventions: Vec<ConventionReport>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub directory_conventions: Vec<DirectoryConvention>,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DirectoryConvention {
pub parent: String,
pub expected_methods: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub expected_registrations: Vec<String>,
pub conforming_dirs: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub outlier_dirs: Vec<DirectoryOutlier>,
pub total_dirs: usize,
pub confidence: f32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DirectoryOutlier {
pub dir: String,
pub missing_methods: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub missing_registrations: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ConventionReport {
pub name: String,
pub glob: String,
pub status: CheckStatus,
pub expected_methods: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub expected_registrations: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub expected_interfaces: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_namespace: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub expected_imports: Vec<String>,
pub conforming: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub outliers: Vec<Outlier>,
pub total_files: usize,
pub confidence: f32,
}
pub fn audit_component(component_id: &str) -> Result<CodeAuditResult> {
let comp = component::load(component_id)?;
component::validate_local_path(&comp)?;
audit_path_with_id(component_id, &comp.local_path)
}
pub fn audit_path(path: &str) -> Result<CodeAuditResult> {
let p = Path::new(path);
if !p.is_dir() {
return Err(crate::Error::validation_invalid_argument(
"path",
format!("Not a directory: {}", path),
None,
None,
));
}
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
audit_path_with_id(&name, path)
}
fn audit_path_with_id(component_id: &str, source_path: &str) -> Result<CodeAuditResult> {
let root = Path::new(source_path);
log_status!("audit", "Scanning {} for conventions...", source_path);
let groups = conventions::auto_discover_groups(root);
if groups.is_empty() {
log_status!("audit", "No source files found");
return Ok(CodeAuditResult {
component_id: component_id.to_string(),
source_path: source_path.to_string(),
summary: AuditSummary {
files_scanned: 0,
conventions_detected: 0,
outliers_found: 0,
alignment_score: 1.0,
},
conventions: vec![],
directory_conventions: vec![],
findings: vec![],
});
}
let mut discovered_conventions = Vec::new();
let mut total_files = 0;
for (name, glob, fingerprints) in &groups {
total_files += fingerprints.len();
if let Some(convention) =
conventions::discover_conventions(name, glob, fingerprints)
{
discovered_conventions.push(convention);
}
}
conventions::check_signature_consistency(&mut discovered_conventions, root);
let check_results = checks::check_conventions(&discovered_conventions);
let all_findings = findings::build_findings(&check_results);
let total_outliers: usize = discovered_conventions.iter().map(|c| c.outliers.len()).sum();
let total_conforming: usize = discovered_conventions.iter().map(|c| c.conforming.len()).sum();
let total_in_conventions = total_conforming + total_outliers;
let alignment_score = if total_in_conventions > 0 {
total_conforming as f32 / total_in_conventions as f32
} else {
1.0
};
let convention_reports: Vec<ConventionReport> = discovered_conventions
.iter()
.zip(check_results.iter())
.map(|(conv, check)| ConventionReport {
name: conv.name.clone(),
glob: conv.glob.clone(),
status: check.status.clone(),
expected_methods: conv.expected_methods.clone(),
expected_registrations: conv.expected_registrations.clone(),
expected_interfaces: conv.expected_interfaces.clone(),
expected_namespace: conv.expected_namespace.clone(),
expected_imports: conv.expected_imports.clone(),
conforming: conv.conforming.clone(),
outliers: conv.outliers.clone(),
total_files: conv.total_files,
confidence: conv.confidence,
})
.collect();
log_status!(
"audit",
"Complete: {} files, {} conventions, {} outliers (alignment: {:.0}%)",
total_files,
convention_reports.len(),
total_outliers,
alignment_score * 100.0
);
let directory_conventions = conventions::discover_cross_directory(&convention_reports);
if !directory_conventions.is_empty() {
let total_dir_outliers: usize = directory_conventions.iter().map(|d| d.outlier_dirs.len()).sum();
log_status!(
"audit",
"Cross-directory: {} pattern(s), {} outlier dir(s)",
directory_conventions.len(),
total_dir_outliers
);
}
Ok(CodeAuditResult {
component_id: component_id.to_string(),
source_path: source_path.to_string(),
summary: AuditSummary {
files_scanned: total_files,
conventions_detected: convention_reports.len(),
outliers_found: total_outliers,
alignment_score,
},
conventions: convention_reports,
directory_conventions,
findings: all_findings,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn audit_nonexistent_path_returns_error() {
let result = audit_path("/nonexistent/path/that/does/not/exist");
assert!(result.is_err());
}
#[test]
fn audit_empty_directory_returns_clean() {
let dir = std::env::temp_dir().join("homeboy_audit_test_empty");
let _ = fs::create_dir_all(&dir);
let result = audit_path(dir.to_str().unwrap()).unwrap();
assert_eq!(result.summary.files_scanned, 0);
assert_eq!(result.summary.alignment_score, 1.0);
assert!(result.conventions.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn audit_directory_with_convention() {
let dir = std::env::temp_dir().join("homeboy_audit_test_conv");
let steps = dir.join("steps");
let _ = fs::create_dir_all(&steps);
fs::write(
steps.join("step_a.php"),
r#"<?php
class StepA {
public function register() {}
public function validate($input) {}
public function execute($ctx) {}
}
"#,
)
.unwrap();
fs::write(
steps.join("step_b.php"),
r#"<?php
class StepB {
public function register() {}
public function validate($input) {}
public function execute($ctx) {}
}
"#,
)
.unwrap();
fs::write(
steps.join("step_c.php"),
r#"<?php
class StepC {
public function register() {}
public function execute($ctx) {}
}
"#,
)
.unwrap();
let result = audit_path(dir.to_str().unwrap()).unwrap();
assert_eq!(result.summary.files_scanned, 3);
assert!(result.summary.conventions_detected >= 1);
assert!(result.summary.outliers_found >= 1);
assert!(result.summary.alignment_score < 1.0);
let steps_conv = result
.conventions
.iter()
.find(|c| c.name == "Steps")
.expect("Should find Steps convention");
assert_eq!(steps_conv.total_files, 3);
assert!(steps_conv.expected_methods.contains(&"register".to_string()));
assert!(steps_conv.expected_methods.contains(&"execute".to_string()));
assert_eq!(steps_conv.outliers.len(), 1);
assert!(steps_conv.outliers[0].file.contains("step_c"));
assert!(!result.findings.is_empty());
assert!(result
.findings
.iter()
.any(|f| f.file.contains("step_c") && f.description.contains("validate")));
let _ = fs::remove_dir_all(&dir);
}
}