use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::{Duration, Instant};
use colored::Colorize;
use fallow_config::{AuditGate, OutputFormat};
use rustc_hash::FxHashSet;
use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
use crate::dupes::{DupesMode, DupesOptions, DupesResult};
use crate::error::emit_error;
use crate::health::{HealthOptions, HealthResult, SortBy};
use crate::report;
use crate::report::plural;
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditVerdict {
Pass,
Warn,
Fail,
}
#[derive(Debug, serde::Serialize)]
pub struct AuditSummary {
pub dead_code_issues: usize,
pub dead_code_has_errors: bool,
pub complexity_findings: usize,
pub max_cyclomatic: Option<u16>,
pub duplication_clone_groups: usize,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct AuditAttribution {
pub gate: AuditGate,
pub dead_code_introduced: usize,
pub dead_code_inherited: usize,
pub complexity_introduced: usize,
pub complexity_inherited: usize,
pub duplication_introduced: usize,
pub duplication_inherited: usize,
}
pub struct AuditResult {
pub verdict: AuditVerdict,
pub summary: AuditSummary,
pub attribution: AuditAttribution,
base_snapshot: Option<AuditKeySnapshot>,
pub changed_files_count: usize,
pub base_ref: String,
pub head_sha: Option<String>,
pub output: OutputFormat,
pub check: Option<CheckResult>,
pub dupes: Option<DupesResult>,
pub health: Option<HealthResult>,
pub elapsed: Duration,
}
pub struct AuditOptions<'a> {
pub root: &'a std::path::Path,
pub config_path: &'a Option<std::path::PathBuf>,
pub output: OutputFormat,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub changed_since: Option<&'a str>,
pub production: bool,
pub production_dead_code: Option<bool>,
pub production_health: Option<bool>,
pub production_dupes: Option<bool>,
pub workspace: Option<&'a [String]>,
pub changed_workspaces: Option<&'a str>,
pub explain: bool,
pub performance: bool,
pub group_by: Option<crate::GroupBy>,
pub dead_code_baseline: Option<&'a std::path::Path>,
pub health_baseline: Option<&'a std::path::Path>,
pub dupes_baseline: Option<&'a std::path::Path>,
pub max_crap: Option<f64>,
pub gate: AuditGate,
}
fn auto_detect_base_branch(root: &std::path::Path) -> Option<String> {
if let Ok(output) = std::process::Command::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.current_dir(root)
.output()
&& output.status.success()
{
let full_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
if let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/") {
return Some(branch.to_string());
}
}
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--verify", "main"])
.current_dir(root)
.output()
&& output.status.success()
{
return Some("main".to_string());
}
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--verify", "master"])
.current_dir(root)
.output()
&& output.status.success()
{
return Some("master".to_string());
}
None
}
fn get_head_sha(root: &std::path::Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(root)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn compute_verdict(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
) -> AuditVerdict {
let mut has_errors = false;
let mut has_warnings = false;
if let Some(result) = check {
if crate::check::has_error_severity_issues(
&result.results,
&result.config.rules,
Some(&result.config),
) {
has_errors = true;
} else if result.results.total_issues() > 0 {
has_warnings = true;
}
}
if let Some(result) = health
&& !result.report.findings.is_empty()
{
has_errors = true;
}
if let Some(result) = dupes
&& !result.report.clone_groups.is_empty()
{
if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
has_errors = true;
} else {
has_warnings = true;
}
}
if has_errors {
AuditVerdict::Fail
} else if has_warnings {
AuditVerdict::Warn
} else {
AuditVerdict::Pass
}
}
fn build_summary(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
) -> AuditSummary {
let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
let dead_code_has_errors = check.is_some_and(|r| {
crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
});
let complexity_findings = health.map_or(0, |r| r.report.findings.len());
let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
AuditSummary {
dead_code_issues,
dead_code_has_errors,
complexity_findings,
max_cyclomatic,
duplication_clone_groups,
}
}
fn compute_audit_attribution(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
base: Option<&AuditKeySnapshot>,
gate: AuditGate,
) -> AuditAttribution {
let dead_code = check
.map(|r| {
count_introduced(
&dead_code_keys(&r.results, &r.config.root),
base.map(|b| &b.dead_code),
)
})
.unwrap_or_default();
let complexity = health
.map(|r| {
count_introduced(
&health_keys(&r.report, &r.config.root),
base.map(|b| &b.health),
)
})
.unwrap_or_default();
let duplication = dupes
.map(|r| {
count_introduced(
&dupes_keys(&r.report, &r.config.root),
base.map(|b| &b.dupes),
)
})
.unwrap_or_default();
AuditAttribution {
gate,
dead_code_introduced: dead_code.0,
dead_code_inherited: dead_code.1,
complexity_introduced: complexity.0,
complexity_inherited: complexity.1,
duplication_introduced: duplication.0,
duplication_inherited: duplication.1,
}
}
fn compute_introduced_verdict(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
base: Option<&AuditKeySnapshot>,
) -> AuditVerdict {
let mut has_errors = false;
let mut has_warnings = false;
if let Some(result) = check {
let base_keys = base.map(|b| &b.dead_code);
let mut introduced = result.results.clone();
retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
if crate::check::has_error_severity_issues(
&introduced,
&result.config.rules,
Some(&result.config),
) {
has_errors = true;
} else if introduced.total_issues() > 0 {
has_warnings = true;
}
}
if let Some(result) = health {
let base_keys = base.map(|b| &b.health);
let introduced = result
.report
.findings
.iter()
.filter(|finding| {
!base_keys.is_some_and(|keys| {
keys.contains(&health_finding_key(finding, &result.config.root))
})
})
.count();
if introduced > 0 {
has_errors = true;
}
}
if let Some(result) = dupes {
let base_keys = base.map(|b| &b.dupes);
let introduced = result
.report
.clone_groups
.iter()
.filter(|group| {
!base_keys
.is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
})
.count();
if introduced > 0 {
if result.threshold > 0.0
&& result.report.stats.duplication_percentage > result.threshold
{
has_errors = true;
} else {
has_warnings = true;
}
}
}
if has_errors {
AuditVerdict::Fail
} else if has_warnings {
AuditVerdict::Warn
} else {
AuditVerdict::Pass
}
}
struct AuditKeySnapshot {
dead_code: FxHashSet<String>,
health: FxHashSet<String>,
dupes: FxHashSet<String>,
}
fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
let Some(base) = base else {
return (0, 0);
};
keys.iter().fold((0, 0), |(introduced, inherited), key| {
if base.contains(key) {
(introduced, inherited + 1)
} else {
(introduced + 1, inherited)
}
})
}
fn compute_base_snapshot(
opts: &AuditOptions<'_>,
base_ref: &str,
) -> Result<AuditKeySnapshot, ExitCode> {
let Some(worktree) = BaseWorktree::create(opts.root, base_ref) else {
return Err(emit_error(
&format!("could not create a temporary worktree for base ref '{base_ref}'"),
2,
opts.output,
));
};
let base_config_path = opts
.config_path
.as_ref()
.filter(|path| path.is_relative())
.map(|path| worktree.path().join(path));
let config_path = if base_config_path.is_some() {
&base_config_path
} else {
opts.config_path
};
let base_opts = AuditOptions {
root: worktree.path(),
config_path,
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: true,
changed_since: None,
production: opts.production,
production_dead_code: opts.production_dead_code,
production_health: opts.production_health,
production_dupes: opts.production_dupes,
workspace: opts.workspace,
changed_workspaces: None,
explain: false,
performance: false,
group_by: opts.group_by,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
max_crap: opts.max_crap,
gate: AuditGate::All,
};
let mut check = run_audit_check(&base_opts, None, false)?;
let dupes = run_audit_dupes(&base_opts, None, None)?;
let health = run_audit_health(&base_opts, None, None)?;
if let Some(ref mut check) = check {
check.shared_parse = None;
}
Ok(AuditKeySnapshot {
dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
dead_code_keys(&r.results, &r.config.root)
}),
health: health.as_ref().map_or_else(FxHashSet::default, |r| {
health_keys(&r.report, &r.config.root)
}),
dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
dupes_keys(&r.report, &r.config.root)
}),
})
}
struct BaseWorktree {
repo_root: PathBuf,
path: PathBuf,
}
impl BaseWorktree {
fn create(repo_root: &Path, base_ref: &str) -> Option<Self> {
sweep_orphan_audit_worktrees(repo_root);
let path = std::env::temp_dir().join(format!(
"fallow-audit-base-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos()
));
let output = Command::new("git")
.args([
"worktree",
"add",
"--detach",
"--quiet",
path.to_str()?,
base_ref,
])
.current_dir(repo_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.output()
.ok()?;
if !output.status.success() {
let _ = std::fs::remove_dir_all(&path);
return None;
}
Some(Self {
repo_root: repo_root.to_path_buf(),
path,
})
}
fn path(&self) -> &Path {
&self.path
}
}
fn sweep_orphan_audit_worktrees(repo_root: &Path) {
let Some(worktrees) = list_audit_worktrees(repo_root) else {
return;
};
let mut removed_any = false;
for path in worktrees {
if !is_fallow_audit_worktree_path(&path) || audit_worktree_process_is_alive(&path) {
continue;
}
let _ = Command::new("git")
.args([
"worktree",
"remove",
"--force",
path.to_string_lossy().as_ref(),
])
.current_dir(repo_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.output();
let _ = std::fs::remove_dir_all(&path);
removed_any = true;
}
if removed_any {
let _ = Command::new("git")
.args(["worktree", "prune", "--expire=now"])
.current_dir(repo_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.output();
}
}
fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(parse_worktree_list(&String::from_utf8_lossy(
&output.stdout,
)))
}
fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
output
.lines()
.filter_map(|line| line.strip_prefix("worktree "))
.map(PathBuf::from)
.filter(|path| is_fallow_audit_worktree_path(path))
.collect()
}
fn is_fallow_audit_worktree_path(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
}
fn path_is_inside_temp_dir(path: &Path) -> bool {
let temp = std::env::temp_dir();
if path.starts_with(&temp) {
return true;
}
let Ok(canonical_temp) = temp.canonicalize() else {
return false;
};
path.starts_with(&canonical_temp)
|| path
.canonicalize()
.is_ok_and(|canonical_path| canonical_path.starts_with(canonical_temp))
}
fn audit_worktree_process_is_alive(path: &Path) -> bool {
let Some(pid) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(audit_worktree_pid)
else {
return false;
};
process_is_alive(pid)
}
fn audit_worktree_pid(name: &str) -> Option<u32> {
name.strip_prefix("fallow-audit-base-")?
.split('-')
.next()?
.parse()
.ok()
}
#[cfg(unix)]
fn process_is_alive(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.is_ok_and(|output| output.status.success())
}
#[cfg(not(unix))]
fn process_is_alive(_pid: u32) -> bool {
true
}
impl Drop for BaseWorktree {
fn drop(&mut self) {
let _ = Command::new("git")
.args([
"worktree",
"remove",
"--force",
self.path.to_string_lossy().as_ref(),
])
.current_dir(&self.repo_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.output();
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn relative_key_path(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn dependency_location_key(location: &fallow_core::results::DependencyLocation) -> &'static str {
match location {
fallow_core::results::DependencyLocation::Dependencies => "unused-dependency",
fallow_core::results::DependencyLocation::DevDependencies => "unused-dev-dependency",
fallow_core::results::DependencyLocation::OptionalDependencies => {
"unused-optional-dependency"
}
}
}
fn unused_dependency_key(item: &fallow_core::results::UnusedDependency, root: &Path) -> String {
format!(
"{}:{}:{}",
dependency_location_key(&item.location),
relative_key_path(&item.path, root),
item.package_name
)
}
fn unlisted_dependency_key(item: &fallow_core::results::UnlistedDependency, root: &Path) -> String {
let mut sites = item
.imported_from
.iter()
.map(|site| {
format!(
"{}:{}:{}",
relative_key_path(&site.path, root),
site.line,
site.col
)
})
.collect::<Vec<_>>();
sites.sort();
sites.dedup();
format!(
"unlisted-dependency:{}:{}",
item.package_name,
sites.join("|")
)
}
fn unused_member_key(
rule_id: &str,
item: &fallow_core::results::UnusedMember,
root: &Path,
) -> String {
format!(
"{}:{}:{}:{}",
rule_id,
relative_key_path(&item.path, root),
item.parent_name,
item.member_name
)
}
fn dead_code_keys(
results: &fallow_core::results::AnalysisResults,
root: &Path,
) -> FxHashSet<String> {
let mut keys = FxHashSet::default();
for item in &results.unused_files {
keys.insert(format!(
"unused-file:{}",
relative_key_path(&item.path, root)
));
}
for item in &results.unused_exports {
keys.insert(format!(
"unused-export:{}:{}",
relative_key_path(&item.path, root),
item.export_name
));
}
for item in &results.unused_types {
keys.insert(format!(
"unused-type:{}:{}",
relative_key_path(&item.path, root),
item.export_name
));
}
for item in &results.private_type_leaks {
keys.insert(format!(
"private-type-leak:{}:{}:{}",
relative_key_path(&item.path, root),
item.export_name,
item.type_name
));
}
for item in results
.unused_dependencies
.iter()
.chain(results.unused_dev_dependencies.iter())
.chain(results.unused_optional_dependencies.iter())
{
keys.insert(unused_dependency_key(item, root));
}
for item in &results.unused_enum_members {
keys.insert(unused_member_key("unused-enum-member", item, root));
}
for item in &results.unused_class_members {
keys.insert(unused_member_key("unused-class-member", item, root));
}
for item in &results.unresolved_imports {
keys.insert(format!(
"unresolved-import:{}:{}",
relative_key_path(&item.path, root),
item.specifier
));
}
for item in &results.unlisted_dependencies {
keys.insert(unlisted_dependency_key(item, root));
}
for item in &results.duplicate_exports {
let mut locations: Vec<String> = item
.locations
.iter()
.map(|loc| relative_key_path(&loc.path, root))
.collect();
locations.sort();
locations.dedup();
keys.insert(format!(
"duplicate-export:{}:{}",
item.export_name,
locations.join("|")
));
}
for item in &results.type_only_dependencies {
keys.insert(format!(
"type-only-dependency:{}:{}",
relative_key_path(&item.path, root),
item.package_name
));
}
for item in &results.test_only_dependencies {
keys.insert(format!(
"test-only-dependency:{}:{}",
relative_key_path(&item.path, root),
item.package_name
));
}
for item in &results.circular_dependencies {
let mut files: Vec<String> = item
.files
.iter()
.map(|path| relative_key_path(path, root))
.collect();
files.sort();
keys.insert(format!("circular-dependency:{}", files.join("|")));
}
for item in &results.boundary_violations {
keys.insert(format!(
"boundary-violation:{}:{}:{}",
relative_key_path(&item.from_path, root),
relative_key_path(&item.to_path, root),
item.import_specifier
));
}
for item in &results.stale_suppressions {
keys.insert(format!(
"stale-suppression:{}:{}",
relative_key_path(&item.path, root),
item.description()
));
}
keys
}
fn retain_introduced_dead_code(
results: &mut fallow_core::results::AnalysisResults,
root: &Path,
base: Option<&FxHashSet<String>>,
) {
let Some(base) = base else {
return;
};
results.unused_files.retain(|item| {
!base.contains(&format!(
"unused-file:{}",
relative_key_path(&item.path, root)
))
});
results.unused_exports.retain(|item| {
!base.contains(&format!(
"unused-export:{}:{}",
relative_key_path(&item.path, root),
item.export_name
))
});
results.unused_types.retain(|item| {
!base.contains(&format!(
"unused-type:{}:{}",
relative_key_path(&item.path, root),
item.export_name
))
});
let introduced = dead_code_keys(results, root)
.into_iter()
.filter(|key| !base.contains(key))
.collect::<FxHashSet<_>>();
let keep = |key: String| introduced.contains(&key);
results.private_type_leaks.retain(|item| {
keep(format!(
"private-type-leak:{}:{}:{}",
relative_key_path(&item.path, root),
item.export_name,
item.type_name
))
});
results
.unused_dependencies
.retain(|item| keep(unused_dependency_key(item, root)));
results
.unused_dev_dependencies
.retain(|item| keep(unused_dependency_key(item, root)));
results
.unused_optional_dependencies
.retain(|item| keep(unused_dependency_key(item, root)));
results
.unused_enum_members
.retain(|item| keep(unused_member_key("unused-enum-member", item, root)));
results
.unused_class_members
.retain(|item| keep(unused_member_key("unused-class-member", item, root)));
results.unresolved_imports.retain(|item| {
keep(format!(
"unresolved-import:{}:{}",
relative_key_path(&item.path, root),
item.specifier
))
});
results
.unlisted_dependencies
.retain(|item| keep(unlisted_dependency_key(item, root)));
results.duplicate_exports.retain(|item| {
let mut locations: Vec<String> = item
.locations
.iter()
.map(|loc| relative_key_path(&loc.path, root))
.collect();
locations.sort();
locations.dedup();
keep(format!(
"duplicate-export:{}:{}",
item.export_name,
locations.join("|")
))
});
results.type_only_dependencies.retain(|item| {
keep(format!(
"type-only-dependency:{}:{}",
relative_key_path(&item.path, root),
item.package_name
))
});
results.test_only_dependencies.retain(|item| {
keep(format!(
"test-only-dependency:{}:{}",
relative_key_path(&item.path, root),
item.package_name
))
});
results.circular_dependencies.retain(|item| {
let mut files: Vec<String> = item
.files
.iter()
.map(|path| relative_key_path(path, root))
.collect();
files.sort();
keep(format!("circular-dependency:{}", files.join("|")))
});
results.boundary_violations.retain(|item| {
keep(format!(
"boundary-violation:{}:{}:{}",
relative_key_path(&item.from_path, root),
relative_key_path(&item.to_path, root),
item.import_specifier
))
});
results.stale_suppressions.retain(|item| {
keep(format!(
"stale-suppression:{}:{}",
relative_key_path(&item.path, root),
item.description()
))
});
}
fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
!base.contains(key)
}
fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
where
I: IntoIterator<Item = bool>,
{
let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
return;
};
for (item, introduced) in items.iter_mut().zip(introduced) {
if let serde_json::Value::Object(map) = item {
map.insert("introduced".to_string(), serde_json::json!(introduced));
}
}
}
#[expect(
clippy::too_many_lines,
reason = "keeps audit attribution keys adjacent to the JSON arrays they annotate"
)]
fn annotate_dead_code_json(
json: &mut serde_json::Value,
results: &fallow_core::results::AnalysisResults,
root: &Path,
base: &FxHashSet<String>,
) {
annotate_issue_array(
json,
"unused_files",
results.unused_files.iter().map(|item| {
issue_was_introduced(
&format!("unused-file:{}", relative_key_path(&item.path, root)),
base,
)
}),
);
annotate_issue_array(
json,
"unused_exports",
results.unused_exports.iter().map(|item| {
issue_was_introduced(
&format!(
"unused-export:{}:{}",
relative_key_path(&item.path, root),
item.export_name
),
base,
)
}),
);
annotate_issue_array(
json,
"unused_types",
results.unused_types.iter().map(|item| {
issue_was_introduced(
&format!(
"unused-type:{}:{}",
relative_key_path(&item.path, root),
item.export_name
),
base,
)
}),
);
annotate_issue_array(
json,
"private_type_leaks",
results.private_type_leaks.iter().map(|item| {
issue_was_introduced(
&format!(
"private-type-leak:{}:{}:{}",
relative_key_path(&item.path, root),
item.export_name,
item.type_name
),
base,
)
}),
);
annotate_issue_array(
json,
"unused_dependencies",
results
.unused_dependencies
.iter()
.map(|item| issue_was_introduced(&unused_dependency_key(item, root), base)),
);
annotate_issue_array(
json,
"unused_dev_dependencies",
results
.unused_dev_dependencies
.iter()
.map(|item| issue_was_introduced(&unused_dependency_key(item, root), base)),
);
annotate_issue_array(
json,
"unused_optional_dependencies",
results
.unused_optional_dependencies
.iter()
.map(|item| issue_was_introduced(&unused_dependency_key(item, root), base)),
);
annotate_issue_array(
json,
"unused_enum_members",
results.unused_enum_members.iter().map(|item| {
issue_was_introduced(&unused_member_key("unused-enum-member", item, root), base)
}),
);
annotate_issue_array(
json,
"unused_class_members",
results.unused_class_members.iter().map(|item| {
issue_was_introduced(&unused_member_key("unused-class-member", item, root), base)
}),
);
annotate_issue_array(
json,
"unresolved_imports",
results.unresolved_imports.iter().map(|item| {
issue_was_introduced(
&format!(
"unresolved-import:{}:{}",
relative_key_path(&item.path, root),
item.specifier
),
base,
)
}),
);
annotate_issue_array(
json,
"unlisted_dependencies",
results
.unlisted_dependencies
.iter()
.map(|item| issue_was_introduced(&unlisted_dependency_key(item, root), base)),
);
annotate_issue_array(
json,
"duplicate_exports",
results.duplicate_exports.iter().map(|item| {
let mut locations: Vec<String> = item
.locations
.iter()
.map(|loc| relative_key_path(&loc.path, root))
.collect();
locations.sort();
locations.dedup();
issue_was_introduced(
&format!(
"duplicate-export:{}:{}",
item.export_name,
locations.join("|")
),
base,
)
}),
);
annotate_issue_array(
json,
"type_only_dependencies",
results.type_only_dependencies.iter().map(|item| {
issue_was_introduced(
&format!(
"type-only-dependency:{}:{}",
relative_key_path(&item.path, root),
item.package_name
),
base,
)
}),
);
annotate_issue_array(
json,
"test_only_dependencies",
results.test_only_dependencies.iter().map(|item| {
issue_was_introduced(
&format!(
"test-only-dependency:{}:{}",
relative_key_path(&item.path, root),
item.package_name
),
base,
)
}),
);
annotate_issue_array(
json,
"circular_dependencies",
results.circular_dependencies.iter().map(|item| {
let mut files: Vec<String> = item
.files
.iter()
.map(|path| relative_key_path(path, root))
.collect();
files.sort();
issue_was_introduced(&format!("circular-dependency:{}", files.join("|")), base)
}),
);
annotate_issue_array(
json,
"boundary_violations",
results.boundary_violations.iter().map(|item| {
issue_was_introduced(
&format!(
"boundary-violation:{}:{}:{}",
relative_key_path(&item.from_path, root),
relative_key_path(&item.to_path, root),
item.import_specifier
),
base,
)
}),
);
annotate_issue_array(
json,
"stale_suppressions",
results.stale_suppressions.iter().map(|item| {
issue_was_introduced(
&format!(
"stale-suppression:{}:{}",
relative_key_path(&item.path, root),
item.description()
),
base,
)
}),
);
}
fn annotate_health_json(
json: &mut serde_json::Value,
report: &crate::health_types::HealthReport,
root: &Path,
base: &FxHashSet<String>,
) {
let Some(items) = json
.get_mut("findings")
.and_then(serde_json::Value::as_array_mut)
else {
return;
};
for (item, finding) in items.iter_mut().zip(&report.findings) {
if let serde_json::Value::Object(map) = item {
map.insert(
"introduced".to_string(),
serde_json::json!(issue_was_introduced(
&health_finding_key(finding, root),
base
)),
);
}
}
}
fn annotate_dupes_json(
json: &mut serde_json::Value,
report: &fallow_core::duplicates::DuplicationReport,
root: &Path,
base: &FxHashSet<String>,
) {
let Some(items) = json
.get_mut("clone_groups")
.and_then(serde_json::Value::as_array_mut)
else {
return;
};
for (item, group) in items.iter_mut().zip(&report.clone_groups) {
if let serde_json::Value::Object(map) = item {
map.insert(
"introduced".to_string(),
serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
);
}
}
}
fn health_keys(report: &crate::health_types::HealthReport, root: &Path) -> FxHashSet<String> {
report
.findings
.iter()
.map(|finding| health_finding_key(finding, root))
.collect()
}
fn health_finding_key(finding: &crate::health_types::HealthFinding, root: &Path) -> String {
format!(
"complexity:{}:{}:{:?}",
relative_key_path(&finding.path, root),
finding.name,
finding.exceeded
)
}
fn dupes_keys(
report: &fallow_core::duplicates::DuplicationReport,
root: &Path,
) -> FxHashSet<String> {
report
.clone_groups
.iter()
.map(|group| dupe_group_key(group, root))
.collect()
}
fn dupe_group_key(group: &fallow_core::duplicates::CloneGroup, root: &Path) -> String {
let mut files: Vec<String> = group
.instances
.iter()
.map(|instance| relative_key_path(&instance.file, root))
.collect();
files.sort();
files.dedup();
let mut hasher = DefaultHasher::new();
for instance in &group.instances {
instance.fragment.hash(&mut hasher);
}
format!(
"dupe:{}:{}:{}:{:x}",
files.join("|"),
group.token_count,
group.line_count,
hasher.finish()
)
}
pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
let start = Instant::now();
let base_ref = resolve_base_ref(opts)?;
let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
return Err(emit_error(
&format!(
"could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
),
2,
opts.output,
));
};
let changed_files_count = changed_files.len();
if changed_files.is_empty() {
return Ok(empty_audit_result(base_ref, opts, start.elapsed()));
}
let changed_since = Some(base_ref.as_str());
let check_production = opts.production_dead_code.unwrap_or(opts.production);
let health_production = opts.production_health.unwrap_or(opts.production);
let dupes_production = opts.production_dupes.unwrap_or(opts.production);
let share_dead_code_parse_with_health = check_production == health_production;
let share_dead_code_files_with_dupes =
share_dead_code_parse_with_health && check_production == dupes_production;
let mut check_result = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
let dupes_files = if share_dead_code_files_with_dupes {
check_result
.as_ref()
.and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
} else {
None
};
let dupes_result = run_audit_dupes(opts, changed_since, dupes_files)?;
let shared_parse = if share_dead_code_parse_with_health {
check_result.as_mut().and_then(|r| r.shared_parse.take())
} else {
None
};
let health_result = run_audit_health(opts, changed_since, shared_parse)?;
let base_snapshot = if matches!(opts.gate, AuditGate::NewOnly) {
Some(compute_base_snapshot(opts, &base_ref)?)
} else {
None
};
let attribution = compute_audit_attribution(
check_result.as_ref(),
dupes_result.as_ref(),
health_result.as_ref(),
base_snapshot.as_ref(),
opts.gate,
);
let verdict = if matches!(opts.gate, AuditGate::NewOnly) {
compute_introduced_verdict(
check_result.as_ref(),
dupes_result.as_ref(),
health_result.as_ref(),
base_snapshot.as_ref(),
)
} else {
compute_verdict(
check_result.as_ref(),
dupes_result.as_ref(),
health_result.as_ref(),
)
};
let summary = build_summary(
check_result.as_ref(),
dupes_result.as_ref(),
health_result.as_ref(),
);
Ok(AuditResult {
verdict,
summary,
attribution,
base_snapshot,
changed_files_count,
base_ref,
head_sha: get_head_sha(opts.root),
output: opts.output,
check: check_result,
dupes: dupes_result,
health: health_result,
elapsed: start.elapsed(),
})
}
fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<String, ExitCode> {
if let Some(ref_str) = opts.changed_since {
return Ok(ref_str.to_string());
}
let Some(branch) = auto_detect_base_branch(opts.root) else {
return Err(emit_error(
"could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
2,
opts.output,
));
};
if let Err(e) = crate::validate::validate_git_ref(&branch) {
return Err(emit_error(
&format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"),
2,
opts.output,
));
}
Ok(branch)
}
fn empty_audit_result(base_ref: String, opts: &AuditOptions<'_>, elapsed: Duration) -> AuditResult {
AuditResult {
verdict: AuditVerdict::Pass,
summary: AuditSummary {
dead_code_issues: 0,
dead_code_has_errors: false,
complexity_findings: 0,
max_cyclomatic: None,
duplication_clone_groups: 0,
},
attribution: AuditAttribution {
gate: opts.gate,
..AuditAttribution::default()
},
base_snapshot: None,
changed_files_count: 0,
base_ref,
head_sha: get_head_sha(opts.root),
output: opts.output,
check: None,
dupes: None,
health: None,
elapsed,
}
}
fn run_audit_check<'a>(
opts: &'a AuditOptions<'a>,
changed_since: Option<&'a str>,
retain_modules_for_health: bool,
) -> Result<Option<CheckResult>, ExitCode> {
let filters = IssueFilters::default();
let trace_opts = TraceOptions {
trace_export: None,
trace_file: None,
trace_dependency: None,
performance: opts.performance,
};
match crate::check::execute_check(&CheckOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
fail_on_issues: false,
filters: &filters,
changed_since,
baseline: opts.dead_code_baseline,
save_baseline: None,
sarif_file: None,
production: opts.production_dead_code.unwrap_or(opts.production),
production_override: opts.production_dead_code,
workspace: opts.workspace,
changed_workspaces: opts.changed_workspaces,
group_by: opts.group_by,
include_dupes: false,
trace_opts: &trace_opts,
explain: opts.explain,
top: None,
file: &[],
include_entry_exports: false,
summary: false,
regression_opts: crate::regression::RegressionOpts {
fail_on_regression: false,
tolerance: crate::regression::Tolerance::Absolute(0),
regression_baseline_file: None,
save_target: crate::regression::SaveRegressionTarget::None,
scoped: true,
quiet: opts.quiet,
},
retain_modules_for_health,
}) {
Ok(r) => Ok(Some(r)),
Err(code) => Err(code),
}
}
fn run_audit_dupes<'a>(
opts: &'a AuditOptions<'a>,
changed_since: Option<&'a str>,
pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
) -> Result<Option<DupesResult>, ExitCode> {
let dupes_cfg = match crate::load_config_for_analysis(
opts.root,
opts.config_path,
opts.output,
opts.no_cache,
opts.threads,
opts.production_dupes
.or_else(|| opts.production.then_some(true)),
opts.quiet,
fallow_config::ProductionAnalysis::Dupes,
) {
Ok(c) => c.duplicates,
Err(code) => return Err(code),
};
let dupes_opts = DupesOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
mode: DupesMode::from(dupes_cfg.mode),
min_tokens: dupes_cfg.min_tokens,
min_lines: dupes_cfg.min_lines,
threshold: dupes_cfg.threshold,
skip_local: dupes_cfg.skip_local,
cross_language: dupes_cfg.cross_language,
ignore_imports: dupes_cfg.ignore_imports,
top: None,
baseline_path: opts.dupes_baseline,
save_baseline_path: None,
production: opts.production_dupes.unwrap_or(opts.production),
production_override: opts.production_dupes,
trace: None,
changed_since,
workspace: opts.workspace,
changed_workspaces: opts.changed_workspaces,
explain: opts.explain,
summary: false,
group_by: opts.group_by,
};
let dupes_run = if let Some(files) = pre_discovered {
crate::dupes::execute_dupes_with_files(&dupes_opts, files)
} else {
crate::dupes::execute_dupes(&dupes_opts)
};
match dupes_run {
Ok(r) => Ok(Some(r)),
Err(code) => Err(code),
}
}
fn run_audit_health<'a>(
opts: &'a AuditOptions<'a>,
changed_since: Option<&'a str>,
shared_parse: Option<crate::health::SharedParseData>,
) -> Result<Option<HealthResult>, ExitCode> {
let health_opts = HealthOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
max_cyclomatic: None,
max_cognitive: None,
max_crap: opts.max_crap,
top: None,
sort: SortBy::Cyclomatic,
production: opts.production_health.unwrap_or(opts.production),
production_override: opts.production_health,
changed_since,
workspace: opts.workspace,
changed_workspaces: opts.changed_workspaces,
baseline: opts.health_baseline,
save_baseline: None,
complexity: true,
file_scores: false,
coverage_gaps: false,
config_activates_coverage_gaps: false,
hotspots: false,
ownership: false,
ownership_emails: None,
targets: false,
force_full: false,
score_only_output: false,
enforce_coverage_gap_gate: false,
effort: None,
score: false,
min_score: None,
since: None,
min_commits: None,
explain: opts.explain,
summary: false,
save_snapshot: None,
trend: false,
group_by: opts.group_by,
coverage: None,
coverage_root: None,
performance: opts.performance,
min_severity: None,
runtime_coverage: None,
};
let health_run = if let Some(shared) = shared_parse {
crate::health::execute_health_with_shared_parse(&health_opts, shared)
} else {
crate::health::execute_health(&health_opts)
};
match health_run {
Ok(r) => Ok(Some(r)),
Err(code) => Err(code),
}
}
#[must_use]
pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
let output = result.output;
let format_exit = match output {
OutputFormat::Json => print_audit_json(result),
OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
print_audit_human(result, quiet, explain, output);
ExitCode::SUCCESS
}
OutputFormat::Sarif => print_audit_sarif(result),
OutputFormat::CodeClimate => print_audit_codeclimate(result),
OutputFormat::Badge => {
eprintln!("Error: badge format is not supported for the audit command");
return ExitCode::from(2);
}
};
if format_exit != ExitCode::SUCCESS {
return format_exit;
}
match result.verdict {
AuditVerdict::Fail => ExitCode::from(1),
AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
}
}
fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
let show_headers = matches!(output, OutputFormat::Human) && !quiet;
if !quiet {
let scope = format_scope_line(result);
eprintln!();
eprintln!("{scope}");
}
let has_check_issues = result.summary.dead_code_issues > 0;
let has_health_findings = result.summary.complexity_findings > 0;
let has_dupe_groups = result.summary.duplication_clone_groups > 0;
let has_any_findings = has_check_issues || has_health_findings || has_dupe_groups;
if has_any_findings {
if show_headers && std::io::stdout().is_terminal() {
println!(
"{}",
"Tip: run `fallow explain <issue-type>` for any finding below.".dimmed()
);
println!();
}
if result.verdict != AuditVerdict::Fail && !quiet {
print_audit_vital_signs(result);
}
if has_check_issues && let Some(ref check) = result.check {
if show_headers {
eprintln!();
eprintln!("── Dead Code ──────────────────────────────────────");
}
crate::check::print_check_result(
check,
crate::check::PrintCheckOptions {
quiet,
explain,
regression_json: false,
group_by: None,
top: None,
summary: false,
show_explain_tip: false,
},
);
}
if has_dupe_groups && let Some(ref dupes) = result.dupes {
if show_headers {
eprintln!();
eprintln!("── Duplication ────────────────────────────────────");
}
crate::dupes::print_dupes_result(dupes, quiet, explain, false, false);
}
if has_health_findings && let Some(ref health) = result.health {
if show_headers {
eprintln!();
eprintln!("── Complexity ─────────────────────────────────────");
}
crate::health::print_health_result(health, quiet, explain, None, None, false, false);
}
}
if !quiet {
print_audit_status_line(result);
}
}
fn format_scope_line(result: &AuditResult) -> String {
let sha_suffix = result
.head_sha
.as_ref()
.map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
format!(
"Audit scope: {} changed file{} vs {}{}",
result.changed_files_count,
plural(result.changed_files_count),
result.base_ref,
sha_suffix
)
}
fn print_audit_vital_signs(result: &AuditResult) {
let mut parts = Vec::new();
parts.push(format!("dead code {}", result.summary.dead_code_issues));
if let Some(max) = result.summary.max_cyclomatic {
parts.push(format!(
"complexity {} (warn, max cyclomatic: {max})",
result.summary.complexity_findings
));
} else {
parts.push(format!("complexity {}", result.summary.complexity_findings));
}
parts.push(format!(
"duplication {}",
result.summary.duplication_clone_groups
));
let line = parts.join(" \u{00b7} ");
println!(
"{} {} {}",
"\u{25a0}".dimmed(),
"Metrics:".dimmed(),
line.dimmed()
);
}
fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
let mut parts = Vec::new();
if summary.dead_code_issues > 0 {
let n = summary.dead_code_issues;
parts.push(format!("dead code: {n} issue{}", plural(n)));
}
if summary.complexity_findings > 0 {
let n = summary.complexity_findings;
parts.push(format!("complexity: {n} finding{}", plural(n)));
}
if summary.duplication_clone_groups > 0 {
let n = summary.duplication_clone_groups;
parts.push(format!("duplication: {n} clone group{}", plural(n)));
}
parts
}
fn print_audit_status_line(result: &AuditResult) {
let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
let n = result.changed_files_count;
let files_str = format!("{n} changed file{}", plural(n));
match result.verdict {
AuditVerdict::Pass => {
eprintln!(
"{}",
format!("\u{2713} No issues in {files_str} ({elapsed_str})")
.green()
.bold()
);
}
AuditVerdict::Warn => {
let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
eprintln!(
"{}",
format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
.green()
.bold()
);
}
AuditVerdict::Fail => {
let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
eprintln!(
"{}",
format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
.red()
.bold()
);
}
}
if !matches!(result.attribution.gate, AuditGate::All) {
let inherited = result.attribution.dead_code_inherited
+ result.attribution.complexity_inherited
+ result.attribution.duplication_inherited;
if inherited > 0 {
eprintln!(
" {}",
format!(
"audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
plural(inherited)
)
.dimmed()
);
}
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "elapsed milliseconds won't exceed u64::MAX"
)]
fn print_audit_json(result: &AuditResult) -> ExitCode {
let mut obj = serde_json::Map::new();
obj.insert("schema_version".into(), serde_json::Value::Number(3.into()));
obj.insert(
"version".into(),
serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
);
obj.insert(
"command".into(),
serde_json::Value::String("audit".to_string()),
);
obj.insert(
"verdict".into(),
serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
);
obj.insert(
"changed_files_count".into(),
serde_json::Value::Number(result.changed_files_count.into()),
);
obj.insert(
"base_ref".into(),
serde_json::Value::String(result.base_ref.clone()),
);
if let Some(ref sha) = result.head_sha {
obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
}
obj.insert(
"elapsed_ms".into(),
serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
);
if let Ok(summary_val) = serde_json::to_value(&result.summary) {
obj.insert("summary".into(), summary_val);
}
if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
obj.insert("attribution".into(), attribution_val);
}
if let Some(ref check) = result.check {
match report::build_json(&check.results, &check.config.root, check.elapsed) {
Ok(mut json) => {
if let Some(ref base) = result.base_snapshot {
annotate_dead_code_json(
&mut json,
&check.results,
&check.config.root,
&base.dead_code,
);
}
obj.insert("dead_code".into(), json);
}
Err(e) => {
return emit_error(
&format!("JSON serialization error: {e}"),
2,
OutputFormat::Json,
);
}
}
}
if let Some(ref dupes) = result.dupes {
match serde_json::to_value(&dupes.report) {
Ok(mut json) => {
let root_prefix = format!("{}/", dupes.config.root.display());
report::strip_root_prefix(&mut json, &root_prefix);
report::inject_dupes_actions(&mut json);
if let Some(ref base) = result.base_snapshot {
annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
}
obj.insert("duplication".into(), json);
}
Err(e) => {
return emit_error(
&format!("JSON serialization error: {e}"),
2,
OutputFormat::Json,
);
}
}
}
if let Some(ref health) = result.health {
match serde_json::to_value(&health.report) {
Ok(mut json) => {
let root_prefix = format!("{}/", health.config.root.display());
report::strip_root_prefix(&mut json, &root_prefix);
report::inject_health_actions(&mut json, crate::health::health_action_opts(health));
if let Some(ref base) = result.base_snapshot {
annotate_health_json(
&mut json,
&health.report,
&health.config.root,
&base.health,
);
}
obj.insert("complexity".into(), json);
}
Err(e) => {
return emit_error(
&format!("JSON serialization error: {e}"),
2,
OutputFormat::Json,
);
}
}
}
report::emit_json(&serde_json::Value::Object(obj), "audit")
}
fn print_audit_sarif(result: &AuditResult) -> ExitCode {
let mut all_runs = Vec::new();
if let Some(ref check) = result.check {
let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
all_runs.extend(runs.iter().cloned());
}
}
if let Some(ref dupes) = result.dupes
&& !dupes.report.clone_groups.is_empty()
{
let run = serde_json::json!({
"tool": {
"driver": {
"name": "fallow",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://github.com/fallow-rs/fallow",
}
},
"automationDetails": { "id": "fallow/audit/dupes" },
"results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
serde_json::json!({
"ruleId": "fallow/code-duplication",
"level": "warning",
"message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
})
}).collect::<Vec<_>>()
});
all_runs.push(run);
}
if let Some(ref health) = result.health {
let sarif = report::build_health_sarif(&health.report, &health.config.root);
if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
all_runs.extend(runs.iter().cloned());
}
}
let combined = serde_json::json!({
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": all_runs,
});
report::emit_json(&combined, "SARIF audit")
}
fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
let mut all_issues = Vec::new();
if let Some(ref check) = result.check
&& let serde_json::Value::Array(items) =
report::build_codeclimate(&check.results, &check.config.root, &check.config.rules)
{
all_issues.extend(items);
}
if let Some(ref dupes) = result.dupes
&& let serde_json::Value::Array(items) =
report::build_duplication_codeclimate(&dupes.report, &dupes.config.root)
{
all_issues.extend(items);
}
if let Some(ref health) = result.health
&& let serde_json::Value::Array(items) =
report::build_health_codeclimate(&health.report, &health.config.root)
{
all_issues.extend(items);
}
report::emit_json(&serde_json::Value::Array(all_issues), "CodeClimate audit")
}
pub fn run_audit(opts: &AuditOptions<'_>) -> ExitCode {
match execute_audit(opts) {
Ok(result) => print_audit_result(&result, opts.quiet, opts.explain),
Err(code) => code,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, process::Command};
fn git(dir: &std::path::Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.expect("git command failed");
assert!(
output.status.success(),
"git {:?} failed\nstdout:\n{}\nstderr:\n{}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
let temp = std::env::temp_dir();
let audit_path = temp.join("fallow-audit-base-123-456");
let canonical_audit_path = temp
.canonicalize()
.unwrap_or_else(|_| temp.clone())
.join("fallow-audit-base-456-789");
let unrelated_temp = temp.join("other-worktree");
let output = format!(
"worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n",
audit_path.display(),
unrelated_temp.display()
);
assert_eq!(parse_worktree_list(&output), vec![audit_path]);
assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
}
#[test]
fn audit_gate_all_skips_base_snapshot() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
.expect("index should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/index.ts"),
"export const legacy = 1;\nexport const changed = 2;\n",
)
.expect("changed module should be written");
let config_path = None;
let opts = AuditOptions {
root,
config_path: &config_path,
output: OutputFormat::Json,
no_cache: true,
threads: 1,
quiet: true,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
max_crap: None,
gate: AuditGate::All,
};
let result = execute_audit(&opts).expect("audit should execute");
assert!(result.base_snapshot.is_none());
assert_eq!(result.attribution.gate, AuditGate::All);
assert_eq!(result.attribution.dead_code_introduced, 0);
assert_eq!(result.attribution.dead_code_inherited, 0);
}
#[test]
fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join("src/index.ts"),
"import { used } from './used';\nused();\n",
)
.expect("index should be written");
fs::write(
root.join("src/used.ts"),
"export function used() {\n return 1;\n}\n",
)
.expect("used module should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/used.ts"),
"export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
)
.expect("changed module should be written");
let config_path = None;
let opts = AuditOptions {
root,
config_path: &config_path,
output: OutputFormat::Json,
no_cache: true,
threads: 1,
quiet: true,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
performance: true,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
max_crap: None,
gate: AuditGate::NewOnly,
};
let result = execute_audit(&opts).expect("audit should execute");
let health = result.health.expect("health should run for changed files");
let timings = health.timings.expect("performance timings should be kept");
assert!(timings.discover_ms.abs() < f64::EPSILON);
assert!(timings.parse_ms.abs() < f64::EPSILON);
assert!(
result.dupes.is_some(),
"dupes should run when changed files exist"
);
}
#[test]
fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join("src/index.ts"),
"import { used } from './used';\nused();\n",
)
.expect("index should be written");
fs::write(
root.join("src/used.ts"),
"export function used() {\n return 1;\n}\n",
)
.expect("used module should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/used.ts"),
"export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
)
.expect("changed module should be written");
let config_path = None;
let opts = AuditOptions {
root,
config_path: &config_path,
output: OutputFormat::Json,
no_cache: true,
threads: 1,
quiet: true,
changed_since: Some("HEAD"),
production: false,
production_dead_code: Some(true),
production_health: Some(false),
production_dupes: Some(false),
workspace: None,
changed_workspaces: None,
explain: false,
performance: true,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
max_crap: None,
gate: AuditGate::NewOnly,
};
let result = execute_audit(&opts).expect("audit should execute");
assert!(result.dupes.is_some(), "dupes should still run");
}
}