pub mod baseline;
mod checks;
mod comment_hygiene;
pub(crate) mod conventions;
pub(crate) mod core_fingerprint;
mod dead_code;
mod discovery;
pub mod docs_audit;
mod duplication;
mod findings;
pub mod fingerprint;
pub mod fixer;
pub(crate) mod impact;
pub(crate) mod import_matching;
mod layer_ownership;
mod naming;
pub(crate) mod preflight;
mod signatures;
mod structural;
mod test_coverage;
pub(crate) mod test_mapping;
mod test_topology;
pub(crate) mod walker;
#[cfg(test)]
pub(crate) mod test_helpers;
use std::path::Path;
use self::layer_ownership::run as run_layer_ownership;
pub use checks::{CheckResult, CheckStatus};
pub use conventions::{AuditFinding, Convention, Deviation, Language, Outlier};
pub use findings::{Finding, Severity};
pub use fingerprint::FileFingerprint;
use crate::{component, utils::is_zero, Result};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AuditSummary {
pub files_scanned: usize,
pub conventions_detected: usize,
#[serde(skip_serializing_if = "is_zero", default)]
pub outliers_found: usize,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub alignment_score: Option<f32>,
#[serde(skip_serializing_if = "is_zero", default)]
pub files_skipped: usize,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
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", default)]
pub directory_conventions: Vec<DirectoryConvention>,
pub findings: Vec<Finding>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub duplicate_groups: Vec<duplication::DuplicateGroup>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DirectoryConvention {
pub parent: String,
pub expected_methods: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub expected_registrations: Vec<String>,
pub conforming_dirs: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub outlier_dirs: Vec<DirectoryOutlier>,
pub total_dirs: usize,
pub confidence: f32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DirectoryOutlier {
pub dir: String,
pub missing_methods: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub missing_registrations: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConventionReport {
pub name: String,
pub glob: String,
pub status: CheckStatus,
pub expected_methods: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub expected_registrations: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub expected_interfaces: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub expected_namespace: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub expected_imports: Vec<String>,
pub conforming: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
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)
}
pub fn audit_path_with_id(component_id: &str, source_path: &str) -> Result<CodeAuditResult> {
audit_internal(component_id, source_path, None, None)
}
pub fn audit_path_scoped(
component_id: &str,
source_path: &str,
file_filter: &[String],
git_ref: Option<&str>,
) -> Result<CodeAuditResult> {
audit_internal(component_id, source_path, Some(file_filter), git_ref)
}
fn audit_internal(
component_id: &str,
source_path: &str,
file_filter: Option<&[String]>,
git_ref: Option<&str>,
) -> Result<CodeAuditResult> {
let root = Path::new(source_path);
if let Some(filter) = file_filter {
log_status!(
"audit",
"Scanning {} changed file(s) in {} for conventions...",
filter.len(),
source_path
);
} else {
log_status!("audit", "Scanning {} for conventions...", source_path);
}
let discovery = discovery::auto_discover_groups(root);
let files_skipped = discovery
.files_walked
.saturating_sub(discovery.files_fingerprinted);
if discovery.groups.is_empty() {
let mut warnings = Vec::new();
let unclaimed = walker::count_unclaimed_source_files(root);
let total_skipped = files_skipped + unclaimed;
if unclaimed > 0 {
warnings.push(format!(
"Found {} source file(s) but no installed extension provides fingerprinting for these file types. \
Install or update an extension with a `provides.file_extensions` and `scripts.fingerprint` config.",
unclaimed
));
log_status!(
"audit",
"WARNING: {} source files found but none could be fingerprinted (no extension claims these file types)",
unclaimed
);
} else if discovery.files_walked > 0 && discovery.files_fingerprinted == 0 {
warnings.push(format!(
"Found {} source file(s) but no extension could fingerprint them.",
discovery.files_walked
));
log_status!(
"audit",
"WARNING: {} source files found but none could be fingerprinted",
discovery.files_walked
);
} else {
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: None,
files_skipped: total_skipped,
warnings,
},
conventions: vec![],
directory_conventions: vec![],
findings: vec![],
duplicate_groups: vec![],
});
}
let mut discovered_conventions = Vec::new();
let mut total_files = 0;
for (name, glob, fingerprints) in &discovery.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 mut all_findings = findings::build_findings(&check_results);
let structural_findings = structural::analyze_structure(root);
if !structural_findings.is_empty() {
log_status!(
"audit",
"Structural: {} finding(s) (god files, high item counts)",
structural_findings.len()
);
all_findings.extend(structural_findings);
}
let all_fingerprints: Vec<&fingerprint::FileFingerprint> = discovery
.groups
.iter()
.flat_map(|(_, _, fps)| fps.iter())
.collect();
let duplication_findings = duplication::detect_duplicates(&all_fingerprints);
let duplicate_groups = duplication::detect_duplicate_groups(&all_fingerprints);
if !duplication_findings.is_empty() {
log_status!(
"audit",
"Duplication: {} finding(s) across {} group(s)",
duplication_findings.len(),
duplicate_groups.len()
);
all_findings.extend(duplication_findings);
}
let intra_dup_findings = duplication::detect_intra_method_duplicates(&all_fingerprints);
if !intra_dup_findings.is_empty() {
log_status!(
"audit",
"Intra-method duplication: {} finding(s) (duplicated blocks within methods)",
intra_dup_findings.len()
);
all_findings.extend(intra_dup_findings);
}
let near_dup_findings = duplication::detect_near_duplicates(&all_fingerprints);
if !near_dup_findings.is_empty() {
log_status!(
"audit",
"Near-duplicates: {} finding(s) (structural matches with different identifiers)",
near_dup_findings.len()
);
all_findings.extend(near_dup_findings);
}
let parallel_findings = duplication::detect_parallel_implementations(&all_fingerprints);
if !parallel_findings.is_empty() {
log_status!(
"audit",
"Parallel implementations: {} finding(s) (similar call patterns in different functions)",
parallel_findings.len()
);
all_findings.extend(parallel_findings);
}
let dead_code_findings = dead_code::analyze_dead_code(&all_fingerprints);
if !dead_code_findings.is_empty() {
log_status!(
"audit",
"Dead code: {} finding(s) (unused params, unreferenced exports, orphaned internals)",
dead_code_findings.len()
);
all_findings.extend(dead_code_findings);
}
let comment_findings = comment_hygiene::run(&all_fingerprints);
if !comment_findings.is_empty() {
log_status!(
"audit",
"Comment hygiene: {} finding(s) (TODO/FIXME/HACK markers, stale phrasing)",
comment_findings.len()
);
all_findings.extend(comment_findings);
}
if let Ok(comp) = component::load(component_id) {
if let Some(extensions) = &comp.extensions {
for ext_id in extensions.keys() {
if let Ok(ext_manifest) = crate::extension::load_extension(ext_id) {
if let Some(test_mapping) = ext_manifest.test_mapping() {
let coverage_findings = test_coverage::analyze_test_coverage(
root,
&all_fingerprints,
test_mapping,
);
if !coverage_findings.is_empty() {
log_status!(
"audit",
"Test coverage: {} finding(s) (missing test files, uncovered methods, orphaned tests)",
coverage_findings.len()
);
all_findings.extend(coverage_findings);
}
break; }
}
}
}
}
let layer_findings = run_layer_ownership(root);
if !layer_findings.is_empty() {
log_status!(
"audit",
"Layer ownership: {} finding(s) (architecture ownership violations)",
layer_findings.len()
);
all_findings.extend(layer_findings);
}
let topology_findings = test_topology::run(root);
if !topology_findings.is_empty() {
log_status!(
"audit",
"Test topology: {} finding(s) (inline/scattered test placement)",
topology_findings.len()
);
all_findings.extend(topology_findings);
}
let doc_findings = detect_doc_drift(root, component_id);
if !doc_findings.is_empty() {
log_status!(
"audit",
"Docs: {} finding(s) (broken references, stale paths)",
doc_findings.len()
);
all_findings.extend(doc_findings);
}
if let Some(filter) = file_filter {
let before = all_findings.len();
let scope_files: std::collections::HashSet<String> = if let Some(ref_str) = git_ref {
let (expanded_scope, affected) =
impact::expand_scope(source_path, ref_str, filter, &all_fingerprints);
if !affected.is_empty() {
log_status!(
"audit",
"Impact: {} affected call-site file(s) added to scope",
affected.len()
);
for af in &affected {
let reason_strs: Vec<String> =
af.reasons.iter().map(|r| r.to_string()).collect();
log_status!(
"audit",
" {} → {} ({})",
af.source_file,
af.file,
reason_strs.join(", ")
);
}
}
expanded_scope
} else {
filter.iter().cloned().collect()
};
all_findings.retain(|f| {
scope_files
.iter()
.any(|scope| f.file.contains(scope.as_str()))
});
let filtered_out = before - all_findings.len();
if filtered_out > 0 {
log_status!(
"audit",
"Scoped: filtered {} finding(s) from out-of-scope files ({} remaining)",
filtered_out,
all_findings.len()
);
}
}
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 {
Some(total_conforming as f32 / total_in_conventions as f32)
} else {
None
};
let mut warnings = Vec::new();
if files_skipped > 0 {
warnings.push(format!(
"{} source file(s) found but could not be fingerprinted (no extension provides fingerprinting for these file types)",
files_skipped
));
}
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.unwrap_or(0.0) * 100.0
);
let directory_conventions = discovery::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,
files_skipped,
warnings,
},
conventions: convention_reports,
directory_conventions,
findings: all_findings,
duplicate_groups,
})
}
fn detect_doc_drift(root: &Path, component_id: &str) -> Vec<Finding> {
use docs_audit::claims::ClaimConfidence;
let mut findings = Vec::new();
let docs_dirs = ["docs", "doc", "documentation"];
let docs_entry = docs_dirs.iter().find_map(|d| {
let p = root.join(d);
if p.is_dir() {
Some((p, *d))
} else {
None
}
});
let Some((docs_path, docs_dir_name)) = docs_entry else {
return findings;
};
let doc_files = docs_audit::find_doc_files(&docs_path, None);
if doc_files.is_empty() {
return findings;
}
let ignore_patterns = if let Ok(comp) = component::load(component_id) {
docs_audit::collect_extension_ignore_patterns(&comp)
} else {
Vec::new()
};
for relative_doc in &doc_files {
let abs_doc = docs_path.join(relative_doc);
let content = match std::fs::read_to_string(&abs_doc) {
Ok(c) => c,
Err(_) => continue,
};
let finding_file = format!("{}/{}", docs_dir_name, relative_doc);
let claims = docs_audit::claims::extract_claims(&content, &finding_file, &ignore_patterns);
for claim in claims {
let result = docs_audit::verify::verify_claim(&claim, root, &docs_path, None);
match result {
docs_audit::VerifyResult::Broken { suggestion } => {
let suggestion_text = suggestion.unwrap_or_default();
let (kind, description) = classify_broken_doc_ref(
&claim.claim_type,
&claim.value,
claim.line,
&suggestion_text,
);
findings.push(Finding {
convention: "docs".to_string(),
severity: match claim.confidence {
ClaimConfidence::Real => Severity::Warning,
ClaimConfidence::Example | ClaimConfidence::Unclear => Severity::Info,
},
file: finding_file.clone(),
description,
suggestion: suggestion_text,
kind,
});
}
docs_audit::VerifyResult::Verified
| docs_audit::VerifyResult::NeedsVerification { .. } => {}
}
}
}
findings.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then_with(|| a.description.cmp(&b.description))
});
findings
}
fn classify_broken_doc_ref(
claim_type: &docs_audit::ClaimType,
value: &str,
line: usize,
suggestion: &str,
) -> (AuditFinding, String) {
let s = suggestion.to_lowercase();
let label = match claim_type {
docs_audit::ClaimType::FilePath => "file reference",
docs_audit::ClaimType::DirectoryPath => "directory reference",
docs_audit::ClaimType::CodeExample => "code example",
docs_audit::ClaimType::ClassName => "class reference",
};
if s.contains("did you mean")
|| s.contains("moved to")
|| s.contains("similar")
|| s.contains("renamed")
{
(
AuditFinding::StaleDocReference,
format!(
"Stale {} `{}` (line {}) — target has moved",
label, value, line
),
)
} else {
(
AuditFinding::BrokenDocReference,
format!(
"Broken {} `{}` (line {}) — target does not exist",
label, value, line
),
)
}
}
#[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!(result.summary.alignment_score.is_none());
assert!(result.conventions.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_analyze_layer_ownership() {
let dir = std::env::temp_dir().join("homeboy_audit_layer_test");
let _ = fs::create_dir_all(dir.join(".homeboy"));
let _ = fs::create_dir_all(dir.join("inc/Core/Steps"));
fs::write(
dir.join(".homeboy/audit-rules.json"),
r#"{
"layer_rules": [
{
"name": "engine-owns-terminal-status",
"forbid": {
"glob": "inc/Core/Steps/**/*.php",
"patterns": ["JobStatus::"]
},
"allow": {"glob": "inc/Abilities/Engine/**/*.php"}
}
]
}"#,
)
.unwrap();
fs::write(
dir.join("inc/Core/Steps/agent_ping.php"),
"<?php\n$status = JobStatus::FAILED;\n",
)
.unwrap();
let findings = layer_ownership::run(&dir);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].convention, "layer_ownership");
let _ = fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "Requires PHP extension with fingerprint script installed"]
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.unwrap() < 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);
}
}