use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::{Duration, Instant};
use fallow_config::{AuditGate, OutputFormat};
use fallow_engine::changed_files::clear_ambient_git_env;
use rustc_hash::{FxHashMap, FxHashSet};
use xxhash_rust::xxh3::xxh3_64;
pub use fallow_api::{AuditAttribution, AuditSummary, AuditVerdict};
#[cfg(test)]
use crate::base_worktree::git_rev_parse;
use crate::base_worktree::{BaseWorktree, git_toplevel, sweep_old_reusable_caches};
use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
use crate::dupes::{DupesMode, DupesOptions, DupesResult};
use crate::error::emit_error;
use crate::health::{HealthOptions, HealthResult};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DupeDemotionDiffSource {
Shared(String),
Worktree,
Skipped,
}
impl DupeDemotionDiffSource {
pub fn label(&self, base_ref: &str) -> String {
match self {
Self::Shared(label) => label.clone(),
Self::Worktree => format!("merge-base worktree diff vs {base_ref}"),
Self::Skipped => "skipped: no diff available".to_string(),
}
}
}
pub struct AuditResult {
pub verdict: AuditVerdict,
pub summary: AuditSummary,
pub attribution: AuditAttribution,
pub dupe_demotion_diff_source: Option<DupeDemotionDiffSource>,
pub base_snapshot: Option<AuditKeySnapshot>,
pub comparison: Option<keys::AuditComparison>,
pub base_snapshot_skipped: bool,
pub changed_files_count: usize,
pub changed_files: Vec<PathBuf>,
pub base_ref: String,
pub base_description: Option<String>,
pub head_sha: Option<String>,
pub output: OutputFormat,
pub performance: bool,
pub check: Option<CheckResult>,
pub dupes: Option<DupesResult>,
pub health: Option<HealthResult>,
pub elapsed: Duration,
pub review_deltas: Option<crate::audit_brief::ReviewDeltas>,
pub weakening_signals: Vec<weakening::WeakeningSignal>,
pub routing: Option<routing::RoutingFacts>,
pub decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
pub graph_snapshot_hash: Option<String>,
pub change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
pub diff_index: Option<fallow_output::DiffIndex>,
}
pub struct AuditOptions<'a> {
pub root: &'a std::path::Path,
pub config_path: &'a Option<std::path::PathBuf>,
pub cache_dir: &'a std::path::Path,
pub output: OutputFormat,
pub json_style: crate::json_style::JsonStyle,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub allow_remote_extends: 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 explain_skipped: 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 health_baseline_mode: fallow_engine::baseline::HealthBaselineMode,
pub max_crap: Option<f64>,
pub coverage: Option<&'a std::path::Path>,
pub coverage_root: Option<&'a std::path::Path>,
pub gate: AuditGate,
pub include_entry_exports: bool,
pub css: bool,
pub css_deep: bool,
pub runtime_coverage: Option<&'a std::path::Path>,
pub min_invocations_hot: u64,
pub brief: bool,
pub max_decisions: usize,
pub walkthrough_guide: bool,
pub walkthrough: bool,
pub mark_viewed: &'a [std::path::PathBuf],
pub show_cleared: bool,
pub walkthrough_file: Option<&'a std::path::Path>,
pub show_deprioritized: bool,
}
#[derive(Clone, Copy, Default)]
pub struct AuditTypeAwareOptions<'a> {
pub enabled: Option<bool>,
pub config_default: Option<bool>,
pub projects: &'a [std::path::PathBuf],
pub require: Option<fallow_config::TypeAwareRequire>,
}
impl AuditTypeAwareOptions<'_> {
const fn cli_enabled(&self) -> bool {
matches!(self.enabled, Some(true))
}
}
#[path = "audit_base_ref.rs"]
mod base_ref;
#[path = "audit_cache.rs"]
mod cache;
#[cfg(test)]
use base_ref::{auto_detect_base_ref, parse_audit_base_override};
use base_ref::{get_head_sha, resolve_base_ref};
#[cfg(test)]
use cache::{
AUDIT_BASE_SNAPSHOT_CACHE_VERSION, CachedAuditKeySnapshot, audit_base_snapshot_cache_dir,
audit_base_snapshot_cache_file, cached_from_snapshot, config_file_fingerprint,
ensure_audit_base_snapshot_cache_dir, snapshot_from_cached,
};
use cache::{
AuditBaseSnapshotCacheKey, audit_base_snapshot_cache_key, load_cached_base_snapshot,
save_cached_base_snapshot, sorted_keys,
};
fn styling_finding_gates(rules: &fallow_config::RulesConfig, code: &str) -> bool {
let severity = match code {
"css-token-drift" => rules.css_token_drift,
"css-duplicate-block" => rules.css_duplicate_block,
"css-selector-complexity" => rules.css_selector_complexity,
"css-dead-surface" => rules.css_dead_surface,
"css-broken-reference" => rules.css_broken_reference,
_ => fallow_config::Severity::Warn,
};
severity == fallow_config::Severity::Error
}
pub struct AuditKeySnapshot {
type_aware_identity: Option<fallow_types::semantic::SemanticAnalysisIdentity>,
type_aware_gap_signature: Vec<String>,
syntactic_dead_code: Option<FxHashSet<String>>,
dead_code: FxHashSet<String>,
health: FxHashSet<String>,
styling: FxHashSet<String>,
dupes: FxHashSet<String>,
boundary_edges: FxHashSet<String>,
cycles: FxHashSet<String>,
public_api: FxHashSet<String>,
}
fn ambient_git_env_hint() -> Option<String> {
use fallow_engine::changed_files::AMBIENT_GIT_ENV_VARS;
for var in AMBIENT_GIT_ENV_VARS {
if let Ok(value) = std::env::var(var)
&& !value.is_empty()
{
return Some(format!(
"{var}={value} is set in the environment; if fallow is being \
invoked from a git hook this can interfere with worktree operations. Re-run \
with `env -u {var} fallow audit` to confirm."
));
}
}
None
}
fn compute_base_snapshot(
opts: &AuditOptions<'_>,
type_aware: AuditTypeAwareOptions<'_>,
base_ref: &str,
base_focus_files: &FxHashSet<PathBuf>,
base_sha: Option<&str>,
) -> Result<AuditKeySnapshot, ExitCode> {
let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
use std::fmt::Write as _;
let mut message =
format!("could not create a temporary worktree for base ref '{base_ref}'");
if let Some(hint) = ambient_git_env_hint() {
let _ = write!(message, "\n hint: {hint}");
}
return Err(emit_error(&message, 2, opts.output));
};
let base_root = base_analysis_root(opts.root, worktree.path());
let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
let current_config_path = opts
.config_path
.clone()
.or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
let base_opts =
build_base_audit_options(opts, &base_root, ¤t_config_path, &base_cache_dir);
let base_changed_files = remap_focus_files(base_focus_files, opts.root, &base_root);
let check_production = opts.production_dead_code.unwrap_or(opts.production);
let health_production = opts.production_health.unwrap_or(opts.production);
let share_dead_code_parse_with_health = check_production == health_production;
let base_changed_files_ref = base_changed_files.as_ref();
let (check_res, dupes_res) = rayon::join(
|| {
run_audit_check(
&base_opts,
type_aware,
None,
base_changed_files_ref,
share_dead_code_parse_with_health,
fallow_config::AnalysisSnapshot::Base,
)
},
|| run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
);
let mut check = check_res?;
let dupes = dupes_res?;
let base_public_api = if opts.brief {
public_api_keys_from_check(check.as_ref(), &base_root)
} else {
FxHashSet::default()
};
let shared_parse = if share_dead_code_parse_with_health {
check.as_mut().and_then(|r| r.shared_parse.take())
} else {
None
};
let health = run_audit_health(&base_opts, None, shared_parse)?;
if let Some(ref mut check) = check {
check.shared_parse = None;
}
Ok(snapshot_from_results(
check.as_ref(),
dupes.as_ref(),
health.as_ref(),
base_public_api,
))
}
fn snapshot_from_results(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
public_api: FxHashSet<String>,
) -> AuditKeySnapshot {
let (boundary_edges, cycles) = check.map_or_else(
|| (FxHashSet::default(), FxHashSet::default()),
|r| {
(
review_deltas::boundary_edge_keys(&r.results.boundary_violations),
review_deltas::cycle_keys(&r.results.circular_dependencies, &r.config.root),
)
},
);
AuditKeySnapshot {
type_aware_identity: check
.and_then(|result| result.type_aware_meta.as_ref())
.and_then(|meta| meta.identity.clone()),
type_aware_gap_signature: check
.and_then(|result| result.type_aware_meta.as_ref())
.map_or_else(Vec::new, type_aware_gap_signature),
syntactic_dead_code: check.and_then(|result| result.syntactic_dead_code_keys.clone()),
dead_code: check.map_or_else(FxHashSet::default, |r| {
dead_code_keys(&r.results, &r.config.root)
}),
health: health.map_or_else(FxHashSet::default, |r| {
health_keys(&r.report, &r.config.root)
}),
styling: health.map_or_else(FxHashSet::default, |r| {
styling_keys(&r.report, &r.config.root)
}),
dupes: dupes.map_or_else(FxHashSet::default, |r| {
dupes_keys(&r.report, &r.config.root)
}),
boundary_edges,
cycles,
public_api,
}
}
fn type_aware_attribution_degrade_reason(
base: Option<&AuditKeySnapshot>,
head: Option<&fallow_types::envelope::TypeAwareMeta>,
) -> Option<&'static str> {
let base = base?;
let base_identity = base.type_aware_identity.as_ref();
let head_identity = head.and_then(|meta| meta.identity.as_ref());
if let (Some(base_identity), Some(head_identity)) = (base_identity, head_identity)
&& !base_identity.incompatible_fields(head_identity).is_empty()
{
return Some("their semantic analysis identities are incompatible");
}
if let Some(head) = head
&& base.type_aware_gap_signature != type_aware_gap_signature(head)
{
return Some("their incomplete semantic query reasons or omissions differ");
}
None
}
fn type_aware_gap_signature(meta: &fallow_types::envelope::TypeAwareMeta) -> Vec<String> {
let mut signature = meta
.queries
.iter()
.filter(|query| query.status != fallow_types::semantic::SemanticCompleteness::Complete)
.map(|query| {
let mut omissions = query
.omissions
.iter()
.map(|omission| format!("{:?}:{}", omission.reason_code, omission.count))
.collect::<Vec<_>>();
omissions.sort();
format!(
"{:?}:{:?}:{}",
query.capability,
query.reason_code,
omissions.join(",")
)
})
.collect::<Vec<_>>();
signature.sort();
signature
}
fn public_api_keys_from_check(check: Option<&CheckResult>, root: &Path) -> FxHashSet<String> {
let Some(check) = check else {
return FxHashSet::default();
};
let Some(graph) = check
.shared_parse
.as_ref()
.and_then(|sp| sp.analysis_output.as_ref())
.and_then(|out| out.graph.as_ref())
else {
return FxHashSet::default();
};
review_deltas::public_export_keys_for(graph, &check.config, &check.workspaces, root)
}
#[expect(
clippy::ref_option,
reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
)]
fn build_base_audit_options<'a>(
opts: &AuditOptions<'a>,
base_root: &'a Path,
current_config_path: &'a Option<PathBuf>,
base_cache_dir: &'a Path,
) -> AuditOptions<'a> {
AuditOptions {
root: base_root,
config_path: current_config_path,
cache_dir: base_cache_dir,
output: opts.output,
json_style: opts.json_style,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: true,
allow_remote_extends: opts.allow_remote_extends,
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,
explain_skipped: false,
performance: false,
group_by: opts.group_by,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
max_crap: opts.max_crap,
coverage: opts.coverage,
coverage_root: opts.coverage_root,
gate: AuditGate::All,
include_entry_exports: opts.include_entry_exports,
css: opts.css,
css_deep: opts.css_deep,
runtime_coverage: None,
min_invocations_hot: opts.min_invocations_hot,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
}
}
fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
let Some(git_root) = git_toplevel(current_root) else {
return base_worktree_root.to_path_buf();
};
let current_root =
dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
match current_root.strip_prefix(&git_root) {
Ok(relative) => base_worktree_root.join(relative),
Err(err) => {
tracing::warn!(
current_root = %current_root.display(),
git_root = %git_root.display(),
error = %err,
"Could not remap audit base root into the base worktree; falling back to worktree root"
);
base_worktree_root.to_path_buf()
}
}
}
fn current_keys_as_base_keys(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
) -> AuditKeySnapshot {
let public_api = check
.and_then(|r| r.public_api_keys.clone())
.unwrap_or_default();
snapshot_from_results(check, dupes, health, public_api)
}
fn can_reuse_current_as_base(
opts: &AuditOptions<'_>,
base_ref: &str,
changed_files: &FxHashSet<PathBuf>,
) -> bool {
let Some(git_root) = git_toplevel(opts.root) else {
return false;
};
let cache_dir = opts.cache_dir.to_path_buf();
let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
let mut reader: Option<BaseFileReader> = None;
for path in changed_files {
if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
continue;
}
if !is_analysis_input(path) {
if is_non_behavioral_doc(path) {
continue;
}
return false;
}
let Ok(current) = std::fs::read_to_string(path) else {
return false;
};
let Ok(relative) = path.strip_prefix(&git_root) else {
return false;
};
let reader = match reader.as_mut() {
Some(reader) => reader,
None => {
let Some(spawned) = BaseFileReader::spawn(opts.root) else {
return false;
};
reader.insert(spawned)
}
};
let base = match reader.read(base_ref, relative) {
BaseRead::Content(base) => base,
BaseRead::Missing | BaseRead::Error => return false,
};
if current == base {
continue;
}
if !js_ts_tokens_equivalent(path, ¤t, &base) {
return false;
}
}
true
}
struct BaseFileReader {
child: Option<crate::signal::ScopedChild>,
stdin: Option<std::process::ChildStdin>,
stdout: std::io::BufReader<std::process::ChildStdout>,
}
impl BaseFileReader {
fn spawn(root: &Path) -> Option<Self> {
let mut command = Command::new("git");
command
.args(["cat-file", "--batch"])
.current_dir(root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
clear_ambient_git_env(&mut command);
let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
let stdin = child.take_stdin()?;
let stdout = child.take_stdout()?;
Some(Self {
child: Some(child),
stdin: Some(stdin),
stdout: std::io::BufReader::new(stdout),
})
}
fn read(&mut self, base_ref: &str, relative: &Path) -> BaseRead {
use std::io::{BufRead, Read};
let relative = relative.to_string_lossy().replace('\\', "/");
if relative.contains('\n') {
return BaseRead::Error;
}
let Some(stdin) = self.stdin.as_mut() else {
return BaseRead::Error;
};
if writeln!(stdin, "{base_ref}:{relative}").is_err() || stdin.flush().is_err() {
return BaseRead::Error;
}
let mut header = String::new();
if !matches!(self.stdout.read_line(&mut header), Ok(n) if n > 0) {
return BaseRead::Error;
}
if header.trim_end().ends_with(" missing") {
return BaseRead::Missing;
}
let Some(size) = header
.trim_end()
.rsplit(' ')
.next()
.and_then(|raw| raw.parse::<usize>().ok())
else {
return BaseRead::Error;
};
let mut buf = vec![0u8; size];
if self.stdout.read_exact(&mut buf).is_err() {
return BaseRead::Error;
}
let mut newline = [0u8; 1];
if self.stdout.read_exact(&mut newline).is_err() {
return BaseRead::Error;
}
BaseRead::Content(String::from_utf8_lossy(&buf).into_owned())
}
}
enum BaseRead {
Content(String),
Missing,
Error,
}
impl Drop for BaseFileReader {
fn drop(&mut self) {
self.stdin.take();
if let Some(child) = self.child.take() {
let _ = child.wait();
}
}
}
fn is_fallow_cache_artifact(
path: &Path,
cache_dir: &Path,
canonical_cache_dir: Option<&Path>,
) -> bool {
path.starts_with(cache_dir)
|| canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
}
fn remap_cache_dir_for_base_worktree(
current_root: &Path,
base_worktree_root: &Path,
cache_dir: &Path,
) -> PathBuf {
if cache_dir.is_absolute()
&& let Ok(relative) = cache_dir.strip_prefix(current_root)
{
return base_worktree_root.join(relative);
}
cache_dir.to_path_buf()
}
fn is_analysis_input(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some(
"js" | "jsx"
| "ts"
| "tsx"
| "mjs"
| "mts"
| "cjs"
| "cts"
| "vue"
| "svelte"
| "astro"
| "mdx"
| "css"
| "scss"
)
)
}
fn is_non_behavioral_doc(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("md" | "markdown" | "txt" | "rst" | "adoc")
)
}
fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
return false;
}
if !matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
) {
return false;
}
fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
}
fn remap_focus_files(
files: &FxHashSet<PathBuf>,
from_root: &Path,
to_root: &Path,
) -> Option<FxHashSet<PathBuf>> {
let simple_from = dunce::simplified(from_root).to_path_buf();
let canonical_from = dunce::canonicalize(from_root).unwrap_or_else(|_| simple_from.clone());
let mut remapped = FxHashSet::default();
for file in files {
let simple_file = dunce::simplified(file);
let relative = simple_file
.strip_prefix(&simple_from)
.or_else(|_| simple_file.strip_prefix(&canonical_from))
.map(Path::to_path_buf)
.ok()
.or_else(|| {
let canonical_file = dunce::canonicalize(file).ok()?;
canonical_file
.strip_prefix(&canonical_from)
.map(Path::to_path_buf)
.ok()
});
if let Some(relative) = relative {
remapped.insert(to_root.join(relative));
}
}
if remapped.is_empty() {
return None;
}
Some(remapped)
}
fn audit_renamed_files(
root: &Path,
base_ref: &str,
) -> Vec<fallow_engine::changed_files::RenamedFile> {
fallow_engine::changed_files::try_get_renamed_files(root, base_ref).unwrap_or_default()
}
fn remap_base_snapshot_for_renames(
snapshot: &mut AuditKeySnapshot,
renames: &[fallow_engine::changed_files::RenamedFile],
root: &Path,
) {
if renames.is_empty() {
return;
}
let rename_map: FxHashMap<String, String> = renames
.iter()
.filter_map(|rename| {
let from = keys::relative_key_path(&rename.from, root);
let to = keys::relative_key_path(&rename.to, root);
(from != to).then_some((from, to))
})
.collect();
if rename_map.is_empty() {
return;
}
snapshot.dead_code = keys::remap_keys_for_renames(&snapshot.dead_code, &rename_map);
snapshot.health = keys::remap_keys_for_renames(&snapshot.health, &rename_map);
snapshot.styling = keys::remap_keys_for_renames(&snapshot.styling, &rename_map);
snapshot.dupes = keys::remap_keys_for_renames(&snapshot.dupes, &rename_map);
snapshot.cycles = keys::remap_keys_for_renames(&snapshot.cycles, &rename_map);
snapshot.public_api = keys::remap_keys_for_renames(&snapshot.public_api, &rename_map);
}
#[cfg(test)]
use std::time::SystemTime;
#[cfg(test)]
use crate::base_worktree::{
ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
record_last_used, remove_audit_worktree, reusable_audit_worktree_path,
reusable_worktree_last_used_path, reusable_worktree_lock_path, reusable_worktree_sha_path,
sweep_orphan_audit_worktrees_in, touch_last_used, unregister_worktree,
};
pub use fallow_api::audit_keys as keys;
#[path = "audit_review_deltas.rs"]
pub mod review_deltas;
#[path = "audit_weakening.rs"]
pub mod weakening;
#[path = "audit_routing.rs"]
pub mod routing;
use keys::{
dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
styling_finding_key, styling_keys,
};
struct HeadAnalyses {
check: Option<CheckResult>,
dupes: Option<DupesResult>,
health: Option<HealthResult>,
}
type HeadAndBaseResult = (
Result<HeadAnalyses, ExitCode>,
Option<Result<AuditKeySnapshot, ExitCode>>,
);
#[expect(
clippy::too_many_arguments,
reason = "HEAD and base analysis inputs stay explicit at the parallel execution boundary"
)]
fn run_audit_head_and_base(
opts: &AuditOptions<'_>,
type_aware: AuditTypeAwareOptions<'_>,
changed_since: Option<&str>,
changed_files: &FxHashSet<PathBuf>,
base_focus_files: &FxHashSet<PathBuf>,
base_ref: &str,
base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
run_base: bool,
) -> HeadAndBaseResult {
if run_base {
let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
let (h, b) = rayon::join(
|| run_audit_head_analyses(opts, type_aware, changed_since, changed_files),
|| compute_base_snapshot(opts, type_aware, base_ref, base_focus_files, base_sha),
);
(h, Some(b))
} else {
(
run_audit_head_analyses(opts, type_aware, changed_since, changed_files),
None,
)
}
}
struct AuditResultParts {
verdict: AuditVerdict,
summary: AuditSummary,
attribution: AuditAttribution,
dupe_demotion_diff_source: Option<DupeDemotionDiffSource>,
base_snapshot: Option<AuditKeySnapshot>,
comparison: Option<keys::AuditComparison>,
base_snapshot_skipped: bool,
changed_files_count: usize,
changed_files: FxHashSet<PathBuf>,
base_ref: String,
base_description: Option<String>,
head_sha: Option<String>,
output: OutputFormat,
performance: bool,
check: Option<CheckResult>,
dupes: Option<DupesResult>,
health: Option<HealthResult>,
elapsed: Duration,
review_deltas: Option<crate::audit_brief::ReviewDeltas>,
weakening_signals: Vec<weakening::WeakeningSignal>,
routing: Option<routing::RoutingFacts>,
decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
graph_snapshot_hash: Option<String>,
change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
diff_index: Option<fallow_output::DiffIndex>,
}
#[derive(Default)]
struct AuditBriefData {
review_deltas: Option<crate::audit_brief::ReviewDeltas>,
weakening_signals: Vec<weakening::WeakeningSignal>,
routing: Option<routing::RoutingFacts>,
decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
graph_snapshot_hash: Option<String>,
change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
diff_index: Option<fallow_output::DiffIndex>,
}
#[derive(Clone, Copy)]
struct AuditBriefDataInput<'a> {
opts: &'a AuditOptions<'a>,
check: Option<&'a CheckResult>,
dupes: Option<&'a DupesResult>,
health: Option<&'a HealthResult>,
base_snapshot: Option<&'a AuditKeySnapshot>,
changed_files: &'a FxHashSet<PathBuf>,
base_ref: &'a str,
head_sha: Option<&'a str>,
}
fn run_audit_head_analyses(
opts: &AuditOptions<'_>,
type_aware: AuditTypeAwareOptions<'_>,
changed_since: Option<&str>,
changed_files: &FxHashSet<PathBuf>,
) -> Result<HeadAnalyses, ExitCode> {
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 = run_audit_check(
opts,
type_aware,
changed_since,
Some(changed_files),
share_dead_code_parse_with_health,
fallow_config::AnalysisSnapshot::Current,
)?;
let dupes_files = if share_dead_code_files_with_dupes {
check
.as_ref()
.and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
} else {
None
};
let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
if opts.brief
&& let Some(ref mut check) = check
{
check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
check.internal_consumers =
compute_brief_internal_consumers(opts.root, check, changed_files);
}
let shared_parse = if share_dead_code_parse_with_health {
check.as_mut().and_then(|r| r.shared_parse.take())
} else {
None
};
let health = run_audit_health(opts, changed_since, shared_parse)?;
Ok(HeadAnalyses {
check,
dupes,
health,
})
}
fn compute_brief_impact_closure(
root: &std::path::Path,
check: &CheckResult,
changed_files: &FxHashSet<PathBuf>,
) -> Option<fallow_engine::module_graph::ImpactClosurePaths> {
let graph = check
.shared_parse
.as_ref()
.and_then(|sp| sp.analysis_output.as_ref())
.and_then(|out| out.graph.as_ref())?;
fallow_engine::module_graph::impact_closure_for_changed_paths(graph, root, changed_files)
}
fn compute_brief_partition_order(
root: &std::path::Path,
check: &CheckResult,
changed_files: &FxHashSet<PathBuf>,
) -> Option<fallow_engine::module_graph::PartitionOrderPaths> {
let graph = check
.shared_parse
.as_ref()
.and_then(|sp| sp.analysis_output.as_ref())
.and_then(|out| out.graph.as_ref())?;
fallow_engine::module_graph::partition_order_for_changed_paths(graph, root, changed_files)
}
fn compute_brief_export_lines(
root: &std::path::Path,
check: &CheckResult,
changed_files: &FxHashSet<PathBuf>,
) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
let graph = check
.shared_parse
.as_ref()
.and_then(|sp| sp.analysis_output.as_ref())
.and_then(|out| out.graph.as_ref())?;
fallow_engine::module_graph::export_lines_for_changed_paths(graph, root, changed_files)
}
fn compute_brief_internal_consumers(
root: &std::path::Path,
check: &CheckResult,
changed_files: &FxHashSet<PathBuf>,
) -> Option<FxHashMap<String, u64>> {
let graph = check
.shared_parse
.as_ref()
.and_then(|sp| sp.analysis_output.as_ref())
.and_then(|out| out.graph.as_ref())?;
fallow_engine::module_graph::internal_consumers_for_changed_paths(graph, root, changed_files)
}
fn compute_brief_focus_facts(
root: &std::path::Path,
check: &CheckResult,
changed_files: &FxHashSet<PathBuf>,
) -> Option<Vec<fallow_engine::module_graph::FocusFileFactsPaths>> {
let graph = check
.shared_parse
.as_ref()
.and_then(|sp| sp.analysis_output.as_ref())
.and_then(|out| out.graph.as_ref())?;
fallow_engine::module_graph::focus_facts_for_changed_paths(graph, root, changed_files)
}
pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
execute_audit_with_type_aware(opts, AuditTypeAwareOptions::default())
}
pub fn execute_audit_with_type_aware(
opts: &AuditOptions<'_>,
type_aware: AuditTypeAwareOptions<'_>,
) -> Result<AuditResult, ExitCode> {
let start = Instant::now();
let (base_ref, base_description) = resolve_base_ref(opts)?;
let Some(mut 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,
));
};
if let Some(walkthrough_file) = opts.walkthrough_file
&& let Ok(walkthrough_file) = dunce::canonicalize(walkthrough_file)
{
changed_files.remove(&walkthrough_file);
}
let changed_files_count = changed_files.len();
if changed_files.is_empty() {
return Ok(empty_audit_result(
base_ref,
base_description,
opts,
start.elapsed(),
));
}
sweep_old_reusable_caches(
opts.root,
crate::base_worktree::resolve_cache_max_age_with_options(
opts.root,
opts.config_path.as_ref(),
opts.allow_remote_extends,
),
opts.quiet,
);
let changed_since = Some(base_ref.as_str());
let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
&& !can_reuse_current_as_base(opts, &base_ref, &changed_files);
let rename_pairs = if needs_real_base_snapshot {
audit_renamed_files(opts.root, &base_ref)
} else {
Vec::new()
};
let base_focus_files: FxHashSet<PathBuf> = if rename_pairs.is_empty() {
changed_files.clone()
} else {
changed_files
.iter()
.cloned()
.chain(rename_pairs.iter().map(|rename| rename.from.clone()))
.collect()
};
let base_cache_key = if needs_real_base_snapshot {
audit_base_snapshot_cache_key(opts, &base_ref, &base_focus_files)?
} else {
None
};
let cached_base_snapshot = if type_aware.cli_enabled() {
None
} else {
base_cache_key
.as_ref()
.and_then(|key| load_cached_base_snapshot(opts, key))
};
let (head_res, base_res) = run_audit_head_and_base(
opts,
type_aware,
changed_since,
&changed_files,
&base_focus_files,
&base_ref,
base_cache_key.as_ref(),
needs_real_base_snapshot && cached_base_snapshot.is_none(),
);
assemble_audit_result(AuditAssemblyInput {
opts,
head_res,
base_res,
cached_base_snapshot,
base_cache_key: if type_aware.cli_enabled() {
None
} else {
base_cache_key
},
changed_files,
changed_files_count,
rename_pairs,
base_ref,
base_description,
start,
})
}
struct AuditAssemblyInput<'a> {
opts: &'a AuditOptions<'a>,
head_res: Result<HeadAnalyses, ExitCode>,
base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
cached_base_snapshot: Option<AuditKeySnapshot>,
base_cache_key: Option<AuditBaseSnapshotCacheKey>,
changed_files: FxHashSet<PathBuf>,
changed_files_count: usize,
rename_pairs: Vec<fallow_engine::changed_files::RenamedFile>,
base_ref: String,
base_description: Option<String>,
start: Instant,
}
#[expect(
clippy::too_many_lines,
reason = "audit assembly keeps compatibility checks and final attribution in one transaction"
)]
fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
let opts = input.opts;
let head = input.head_res?;
let mut check_result = head.check;
let dupes_result = head.dupes;
let mut health_result = head.health;
let (mut base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
opts,
input.cached_base_snapshot,
input.base_res,
input.base_cache_key.as_ref(),
CurrentAnalysisRefs {
check: check_result.as_ref(),
dupes: dupes_result.as_ref(),
health: health_result.as_ref(),
},
)?;
if !base_snapshot_skipped && let Some(snapshot) = base_snapshot.as_mut() {
remap_base_snapshot_for_renames(snapshot, &input.rename_pairs, opts.root);
}
let type_aware_degrade = type_aware_attribution_degrade_reason(
base_snapshot.as_ref(),
check_result
.as_ref()
.and_then(|result| result.type_aware_meta.as_ref()),
);
if let Some(reason) = type_aware_degrade {
let warning = format!(
"audit compared base and head with syntactic attribution because {reason} \
(usually a tsconfig or compiler-options change between base and head); \
type-aware refinement still applies to head findings, and \
semantic-only findings stay out of the new-only gate for this run; set \
audit.typeAware: false or pass --no-type-aware to keep the gate syntactic"
);
if matches!(opts.output, fallow_config::OutputFormat::Human) && !opts.quiet {
eprintln!(
"{}",
crate::report::human_status_line(
crate::report::HumanStatus::Warning,
format_args!("Type-aware: {warning}")
)
);
}
if let Some(check) = check_result.as_mut() {
check.type_aware_warnings.push(warning.clone());
if let Some(meta) = check.type_aware_meta.as_mut() {
meta.warnings.push(warning);
meta.warning_count = meta.warnings.len();
}
}
}
drop_check_shared_parse(&mut check_result);
let mut comparison = build_cli_audit_comparison(
check_result.as_ref(),
dupes_result.as_ref(),
health_result.as_ref(),
base_snapshot.as_ref(),
type_aware_degrade.is_some(),
);
let dupe_demotion_diff_source = demote_preexisting_dupe_introductions(
&mut comparison,
dupes_result.as_ref(),
opts.root,
&input.base_ref,
);
let (attribution, verdict, summary) = compute_comparison_audit_outcome(
opts.gate,
dupes_result.as_ref(),
health_result.as_ref(),
&comparison,
base_snapshot.is_some(),
);
if base_snapshot.is_some() {
if let Some(check) = check_result.as_mut() {
comparison.dead_code.annotate_results(&mut check.results);
}
if let Some(health) = health_result.as_mut() {
for (finding, introduced) in health
.report
.findings
.iter_mut()
.zip(comparison.health.introduced())
{
finding.introduced = Some(introduced);
}
}
}
let head_sha = get_head_sha(opts.root);
let brief = compute_audit_brief_data(AuditBriefDataInput {
opts,
check: check_result.as_ref(),
dupes: dupes_result.as_ref(),
health: health_result.as_ref(),
base_snapshot: base_snapshot.as_ref(),
changed_files: &input.changed_files,
base_ref: &input.base_ref,
head_sha: head_sha.as_deref(),
});
Ok(build_audit_result(AuditResultParts {
verdict,
summary,
attribution,
dupe_demotion_diff_source,
base_snapshot,
comparison: Some(comparison),
base_snapshot_skipped,
changed_files_count: input.changed_files_count,
changed_files: input.changed_files,
base_ref: input.base_ref,
base_description: input.base_description,
head_sha,
output: opts.output,
performance: opts.performance,
check: check_result,
dupes: dupes_result,
health: health_result,
elapsed: input.start.elapsed(),
review_deltas: brief.review_deltas,
weakening_signals: brief.weakening_signals,
routing: brief.routing,
decision_surface: brief.decision_surface,
graph_snapshot_hash: brief.graph_snapshot_hash,
change_anchors: brief.change_anchors,
diff_index: brief.diff_index,
}))
}
fn drop_check_shared_parse(check_result: &mut Option<CheckResult>) {
if let Some(check) = check_result {
check.shared_parse = None;
}
}
fn compute_audit_brief_data(input: AuditBriefDataInput<'_>) -> AuditBriefData {
if !input.opts.brief {
return AuditBriefData::default();
}
let (review_deltas, weakening_signals, routing) = compute_brief_e3_data(
input.opts,
input.check,
input.base_snapshot,
input.changed_files,
input.base_ref,
);
let decision_surface = Some(compute_decision_surface(
input.opts,
input.check,
review_deltas.as_ref(),
routing.as_ref(),
));
let diff_evidence =
compute_brief_diff_evidence(input.opts.root, input.base_ref, input.opts.walkthrough_file);
let change_anchors = diff_evidence.change_anchors;
let graph_snapshot_hash = Some(compute_graph_snapshot_hash(
input.check,
input.dupes,
input.health,
input.base_ref,
input.head_sha,
&change_anchors,
));
AuditBriefData {
review_deltas,
weakening_signals,
routing,
decision_surface,
graph_snapshot_hash,
change_anchors,
diff_index: diff_evidence.diff_index,
}
}
fn compute_graph_snapshot_hash(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
base_ref: &str,
head_sha: Option<&str>,
change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
) -> String {
let public_api = check
.and_then(|c| c.public_api_keys.clone())
.unwrap_or_default();
let snapshot = snapshot_from_results(check, dupes, health, public_api);
let mut bytes: Vec<u8> = Vec::new();
for set in [
&snapshot.dead_code,
&snapshot.health,
&snapshot.dupes,
&snapshot.boundary_edges,
&snapshot.cycles,
&snapshot.public_api,
] {
for key in sorted_keys(set) {
bytes.extend_from_slice(key.as_bytes());
bytes.push(0);
}
bytes.push(1);
}
let mut anchor_ids: Vec<&str> = change_anchors
.iter()
.map(|a| a.change_anchor.as_str())
.collect();
anchor_ids.sort_unstable();
for id in anchor_ids {
bytes.extend_from_slice(id.as_bytes());
bytes.push(0);
}
bytes.push(1);
bytes.extend_from_slice(base_ref.as_bytes());
bytes.push(0);
bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
format!("graph:{:016x}", xxh3_64(&bytes))
}
#[derive(Default)]
struct BriefDiffEvidence {
change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
diff_index: Option<fallow_output::DiffIndex>,
}
fn compute_brief_diff_evidence(
root: &std::path::Path,
base_ref: &str,
walkthrough_file: Option<&std::path::Path>,
) -> BriefDiffEvidence {
let excluded_file = walkthrough_file_relative_to_root(root, walkthrough_file);
if let (Some(raw), Some(index)) = (
crate::report::ci::diff_filter::shared_diff_raw(),
crate::report::ci::diff_filter::shared_diff_index(),
) {
let mut change_anchors = crate::audit_walkthrough::parse_change_anchors(raw);
if let Some(excluded) = excluded_file.as_deref() {
change_anchors.retain(|anchor| anchor.file != excluded);
}
return BriefDiffEvidence {
change_anchors,
diff_index: Some(index.clone()),
};
}
let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) else {
return BriefDiffEvidence::default();
};
let mut change_anchors = crate::audit_walkthrough::parse_change_anchors(&diff);
if let Some(excluded) = excluded_file.as_deref() {
change_anchors.retain(|anchor| anchor.file != excluded);
}
BriefDiffEvidence {
change_anchors,
diff_index: Some(fallow_output::DiffIndex::from_unified_diff(&diff)),
}
}
fn walkthrough_file_relative_to_root(
root: &Path,
walkthrough_file: Option<&Path>,
) -> Option<String> {
let root = dunce::canonicalize(root).ok()?;
let file = dunce::canonicalize(walkthrough_file?).ok()?;
let relative = file.strip_prefix(root).ok()?;
Some(relative.to_string_lossy().replace('\\', "/"))
}
fn compute_decision_surface(
opts: &AuditOptions<'_>,
check: Option<&CheckResult>,
review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
routing: Option<&routing::RoutingFacts>,
) -> crate::audit_decision_surface::DecisionSurface {
use crate::audit_decision_surface::{
CoordinationAnchor, DecisionInputs, extract_decision_surface,
};
let (Some(check), Some(deltas)) = (check, review_deltas) else {
return crate::audit_decision_surface::DecisionSurface::default();
};
let root = &check.config.root;
let boundary_anchors = decision_boundary_anchors(check, deltas, root);
let closure = check.impact_closure.as_ref();
let mut coordination: Vec<CoordinationAnchor> = closure
.map(|c| aggregate_coordination_gaps(&c.coordination_gap))
.unwrap_or_default();
let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
let empty_routing = routing::RoutingFacts::default();
let routing = routing.unwrap_or(&empty_routing);
let root_owned = root.clone();
let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
for anchor in &mut coordination {
anchor.line = resolve_export_line(
check.export_lines.as_ref(),
&anchor.changed_file,
&anchor.consumed_symbols,
);
}
let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
let mut parts = key.splitn(2, "::");
let path = parts.next().unwrap_or_default();
let name = parts.next().unwrap_or_default();
resolve_export_line(check.export_lines.as_ref(), path, &[name.to_string()])
});
let rename_old_path = |rel: &str| -> Option<String> {
crate::report::ci::diff_filter::shared_diff_index()
.and_then(|idx| idx.old_path_for_root_relative(rel))
.map(std::borrow::Cow::into_owned)
};
let internal_consumers_map = check.internal_consumers.as_ref();
let internal_consumers = |rel: &str| -> u64 {
internal_consumers_map
.and_then(|map| map.get(rel))
.copied()
.unwrap_or(0)
};
extract_decision_surface(&DecisionInputs {
deltas,
boundary_anchors: &boundary_anchors,
coordination: &coordination,
public_api_anchor_line,
affected_not_shown,
routing,
head_source: &head_source,
rename_old_path: &rename_old_path,
internal_consumers: &internal_consumers,
cap: opts.max_decisions,
})
}
fn decision_boundary_anchors(
check: &CheckResult,
deltas: &crate::audit_brief::ReviewDeltas,
root: &std::path::Path,
) -> Vec<crate::audit_decision_surface::BoundaryAnchor> {
use crate::audit_decision_surface::BoundaryAnchor;
let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
for finding in &check.results.boundary_violations {
let key = review_deltas::boundary_edge_key(finding);
if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
continue;
}
boundary_anchors.push(BoundaryAnchor {
zone_pair_key: key,
from_file: keys::relative_key_path(&finding.violation.from_path, root),
from_zone: finding.violation.from_zone.clone(),
to_zone: finding.violation.to_zone.clone(),
line: finding.violation.line,
});
}
boundary_anchors
}
fn resolve_export_line(
export_lines: Option<&FxHashMap<String, Vec<(String, u32)>>>,
rel: &str,
symbols: &[String],
) -> u32 {
let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
return 0;
};
exports
.iter()
.find(|(name, _)| symbols.iter().any(|s| name == s))
.or_else(|| exports.first())
.map_or(0, |(_, line)| *line)
}
fn aggregate_coordination_gaps(
gaps: &[fallow_engine::module_graph::CoordinationGapPaths],
) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
use crate::audit_decision_surface::CoordinationAnchor;
let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
for gap in gaps {
let entry = by_file
.entry(gap.changed_file.clone())
.or_insert_with(|| (0, FxHashSet::default()));
entry.0 += 1;
for symbol in &gap.consumed_symbols {
entry.1.insert(symbol.clone());
}
}
let mut anchors: Vec<CoordinationAnchor> = by_file
.into_iter()
.map(|(changed_file, (consumer_count, symbols))| {
let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
consumed_symbols.sort_unstable();
CoordinationAnchor {
changed_file,
consumed_symbols,
consumer_count,
line: 0,
}
})
.collect();
anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
anchors
}
fn compute_brief_e3_data(
opts: &AuditOptions<'_>,
check: Option<&CheckResult>,
base_snapshot: Option<&AuditKeySnapshot>,
changed_files: &FxHashSet<PathBuf>,
base_ref: &str,
) -> (
Option<crate::audit_brief::ReviewDeltas>,
Vec<weakening::WeakeningSignal>,
Option<routing::RoutingFacts>,
) {
let deltas = check.zip(base_snapshot).map(|(check, base)| {
let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
let head_cycles =
review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
let head_public_api = check.public_api_keys.clone().unwrap_or_default();
crate::audit_brief::build_review_deltas(
&head_boundary,
&base.boundary_edges,
&head_cycles,
&base.cycles,
&head_public_api,
&base.public_api,
)
});
let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
let routing =
check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
(deltas, weakening_signals, routing)
}
fn compute_weakening_signals(
root: &Path,
base_ref: &str,
changed_files: &FxHashSet<PathBuf>,
) -> Vec<weakening::WeakeningSignal> {
let Some(git_root) = git_toplevel(root) else {
return Vec::new();
};
let Some(mut reader) = BaseFileReader::spawn(root) else {
return Vec::new();
};
let mut signals = Vec::new();
let mut files: Vec<&PathBuf> = changed_files.iter().collect();
files.sort();
for abs in files {
let Ok(relative) = abs.strip_prefix(&git_root) else {
continue;
};
let rel_str = relative.to_string_lossy().replace('\\', "/");
let head = match std::fs::read(abs) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(_) => continue,
};
let base = match reader.read(base_ref, relative) {
BaseRead::Content(base) => base,
BaseRead::Missing => String::new(),
BaseRead::Error => break,
};
signals.extend(weakening_signals_for_file(&rel_str, &base, &head));
}
signals
}
fn weakening_signals_for_file(
rel_str: &str,
base: &str,
head: &str,
) -> Vec<weakening::WeakeningSignal> {
use weakening::WeakeningKind;
let mut signals = Vec::new();
if weakening::is_test_file(rel_str) {
extend_weakening_signals(
&mut signals,
WeakeningKind::TestWeakened,
rel_str,
weakening::detect_test_weakening(base, head)
.into_iter()
.map(|token| format!("{token} added")),
);
extend_weakening_signals(
&mut signals,
WeakeningKind::TestWeakened,
rel_str,
weakening::detect_removed_tests(base, head),
);
}
extend_weakening_signals(
&mut signals,
WeakeningKind::SuppressionAdded,
rel_str,
weakening::detect_added_suppressions(base, head),
);
extend_weakening_signals(
&mut signals,
WeakeningKind::ThresholdLowered,
rel_str,
weakening::detect_lowered_thresholds(base, head),
);
if weakening::is_ci_file(rel_str) {
extend_weakening_signals(
&mut signals,
WeakeningKind::SecurityCheckRemoved,
rel_str,
weakening::detect_removed_security_steps(base, head),
);
}
signals
}
fn extend_weakening_signals(
signals: &mut Vec<weakening::WeakeningSignal>,
kind: weakening::WeakeningKind,
file: &str,
evidences: impl IntoIterator<Item = String>,
) {
signals.extend(
evidences
.into_iter()
.map(|evidence| weakening::WeakeningSignal {
kind,
file: file.to_owned(),
evidence,
}),
);
}
fn build_cli_audit_comparison(
check: Option<&CheckResult>,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
base: Option<&AuditKeySnapshot>,
syntactic_dead_code_fallback: bool,
) -> keys::AuditComparison {
let dead_code = check.map_or_else(keys::DeadCodeAuditLedger::default, |result| {
let base_keys = base.map(|snapshot| {
if syntactic_dead_code_fallback {
snapshot
.syntactic_dead_code
.as_ref()
.unwrap_or(&snapshot.dead_code)
} else {
&snapshot.dead_code
}
});
let mut ledger = keys::dead_code_audit_ledger(
&result.results,
&result.config.root,
&result.config,
base_keys,
);
if syntactic_dead_code_fallback
&& let Some(head_syntactic) = result.syntactic_dead_code_keys.as_ref()
{
ledger.demote_unattributable_introductions(head_syntactic);
}
ledger
});
let health_ledger = keys::AuditDomainLedger::compare(
health.into_iter().flat_map(|result| {
result
.report
.findings
.iter()
.map(move |finding| health_finding_key(finding, &result.config.root))
}),
base.map(|snapshot| &snapshot.health),
);
let dupes_ledger = keys::AuditDomainLedger::compare(
dupes.into_iter().flat_map(|result| {
result
.report
.clone_groups
.iter()
.map(move |group| dupe_group_key(group, &result.config.root))
}),
base.map(|snapshot| &snapshot.dupes),
);
let styling = keys::AuditDomainLedger::compare(
health.into_iter().flat_map(|result| {
result
.report
.styling_findings
.iter()
.map(move |finding| styling_finding_key(finding, &result.config.root))
}),
base.map(|snapshot| &snapshot.styling),
);
keys::AuditComparison {
dead_code,
health: health_ledger,
dupes: dupes_ledger,
styling,
}
}
fn demote_preexisting_dupe_introductions(
comparison: &mut keys::AuditComparison,
dupes: Option<&DupesResult>,
root: &Path,
base_ref: &str,
) -> Option<DupeDemotionDiffSource> {
if comparison.dupes.introduced_count() == 0 {
return None;
}
let dupes = dupes?;
let fallback_index;
let (index, source) = if let Some(shared) = crate::report::ci::diff_filter::shared_diff_index()
{
let label = crate::report::ci::diff_filter::shared_diff_source_label()
.unwrap_or("shared diff")
.to_owned();
(shared, DupeDemotionDiffSource::Shared(label))
} else if let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) {
fallback_index = fallow_output::DiffIndex::from_unified_diff(&diff);
(&fallback_index, DupeDemotionDiffSource::Worktree)
} else {
return Some(DupeDemotionDiffSource::Skipped);
};
let demote = keys::preexisting_dupe_group_keys(
dupes.report.clone_groups.iter(),
&dupes.config.root,
index,
);
comparison.dupes.demote_introductions(&demote);
Some(source)
}
fn compute_comparison_audit_outcome(
gate: AuditGate,
dupes: Option<&DupesResult>,
health: Option<&HealthResult>,
comparison: &keys::AuditComparison,
has_base: bool,
) -> (AuditAttribution, AuditVerdict, AuditSummary) {
let new_only = matches!(gate, AuditGate::NewOnly);
let dead_code_errors = if new_only {
comparison.dead_code.has_introduced_errors()
} else {
comparison.dead_code.has_errors()
};
let dead_code_warnings = if new_only {
comparison.dead_code.has_introduced_warnings()
} else {
comparison
.dead_code
.records()
.iter()
.any(|record| record.effective_severity == fallow_config::Severity::Warn)
};
let complexity_findings = if new_only {
comparison.health.introduced_count()
} else {
health.map_or(0, |result| result.report.findings.len())
};
let styling_errors = health.is_some_and(|result| {
result
.report
.styling_findings
.iter()
.zip(comparison.styling.introduced())
.any(|(finding, introduced)| {
(!new_only || introduced)
&& styling_finding_gates(&result.config.rules, &finding.code)
})
});
let duplication_findings = if new_only {
comparison.dupes.introduced_count()
} else {
dupes.map_or(0, |result| result.report.clone_groups.len())
};
let duplication_errors = dupes.is_some_and(|result| {
duplication_findings > 0
&& result.threshold > 0.0
&& result.report.stats.duplication_percentage > result.threshold
});
let verdict =
if dead_code_errors || complexity_findings > 0 || styling_errors || duplication_errors {
AuditVerdict::Fail
} else if dead_code_warnings || duplication_findings > 0 {
AuditVerdict::Warn
} else {
AuditVerdict::Pass
};
let attribution = if has_base {
AuditAttribution {
gate,
dead_code_introduced: comparison.dead_code.introduced_count(),
dead_code_inherited: comparison.dead_code.inherited_count(),
complexity_introduced: comparison.health.introduced_count(),
complexity_inherited: comparison.health.inherited_count(),
duplication_introduced: comparison.dupes.introduced_count(),
duplication_inherited: comparison.dupes.inherited_count(),
}
} else {
AuditAttribution {
gate,
..AuditAttribution::default()
}
};
let summary = AuditSummary {
dead_code_issues: comparison.dead_code.visible_count(),
dead_code_has_errors: comparison.dead_code.has_errors(),
complexity_findings: health.map_or(0, |result| result.report.findings.len()),
max_cyclomatic: health.and_then(|result| {
result
.report
.findings
.iter()
.map(|finding| finding.cyclomatic)
.max()
}),
duplication_clone_groups: dupes.map_or(0, |result| result.report.clone_groups.len()),
};
crate::telemetry::note_final_result_count(
summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
);
(attribution, verdict, summary)
}
#[derive(Clone, Copy)]
struct CurrentAnalysisRefs<'a> {
check: Option<&'a CheckResult>,
dupes: Option<&'a DupesResult>,
health: Option<&'a HealthResult>,
}
fn resolve_base_snapshot(
opts: &AuditOptions<'_>,
cached_base_snapshot: Option<AuditKeySnapshot>,
base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
current: CurrentAnalysisRefs<'_>,
) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
if !matches!(opts.gate, AuditGate::NewOnly) {
return Ok((None, false));
}
if let Some(snapshot) = cached_base_snapshot {
return Ok((Some(snapshot), false));
}
if let Some(base_res) = base_res {
let snapshot = base_res?;
if let Some(key) = base_cache_key {
save_cached_base_snapshot(opts, key, &snapshot);
}
return Ok((Some(snapshot), false));
}
let CurrentAnalysisRefs {
check,
dupes,
health,
} = current;
Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
}
fn build_audit_result(parts: AuditResultParts) -> AuditResult {
AuditResult {
verdict: parts.verdict,
summary: parts.summary,
attribution: parts.attribution,
dupe_demotion_diff_source: parts.dupe_demotion_diff_source,
base_snapshot: parts.base_snapshot,
comparison: parts.comparison,
base_snapshot_skipped: parts.base_snapshot_skipped,
changed_files_count: parts.changed_files_count,
changed_files: parts.changed_files.into_iter().collect(),
base_ref: parts.base_ref,
base_description: parts.base_description,
head_sha: parts.head_sha,
output: parts.output,
performance: parts.performance,
check: parts.check,
dupes: parts.dupes,
health: parts.health,
elapsed: parts.elapsed,
review_deltas: parts.review_deltas,
weakening_signals: parts.weakening_signals,
routing: parts.routing,
decision_surface: parts.decision_surface,
graph_snapshot_hash: parts.graph_snapshot_hash,
change_anchors: parts.change_anchors,
diff_index: parts.diff_index,
}
}
fn empty_audit_result(
base_ref: String,
base_description: Option<String>,
opts: &AuditOptions<'_>,
elapsed: Duration,
) -> AuditResult {
crate::telemetry::note_final_result_count(0);
let head_sha = get_head_sha(opts.root);
let graph_snapshot_hash = if opts.brief {
Some(compute_graph_snapshot_hash(
None,
None,
None,
&base_ref,
head_sha.as_deref(),
&[],
))
} else {
None
};
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()
},
dupe_demotion_diff_source: None,
base_snapshot: None,
comparison: None,
base_snapshot_skipped: false,
changed_files_count: 0,
changed_files: Vec::new(),
base_ref,
base_description,
head_sha,
output: opts.output,
performance: opts.performance,
check: None,
dupes: None,
health: None,
elapsed,
review_deltas: None,
weakening_signals: Vec::new(),
routing: None,
decision_surface: None,
graph_snapshot_hash,
change_anchors: Vec::new(),
diff_index: None,
}
}
fn run_audit_check<'a>(
opts: &'a AuditOptions<'a>,
type_aware: AuditTypeAwareOptions<'a>,
changed_since: Option<&'a str>,
changed_files: Option<&FxHashSet<PathBuf>>,
retain_modules_for_health: bool,
analysis_snapshot: fallow_config::AnalysisSnapshot,
) -> Result<Option<CheckResult>, ExitCode> {
let filters = IssueFilters::default();
let retain_modules_for_health = retain_modules_for_health || opts.brief;
let trace_opts = TraceOptions {
trace_export: None,
trace_file: None,
trace_dependency: None,
impact_closure: None,
symbol_impact: None,
performance: opts.performance,
};
match crate::check::execute_check(&CheckOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
json_style: opts.json_style,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
allow_remote_extends: opts.allow_remote_extends,
fail_on_issues: false,
filters: &filters,
changed_since,
diff_index: None,
use_shared_diff_index: true,
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,
type_aware: type_aware.enabled,
type_aware_config_override: type_aware.config_default,
type_aware_projects: type_aware.projects,
type_aware_require: type_aware.require,
trace_opts: &trace_opts,
explain: opts.explain,
top: None,
file: &[],
include_entry_exports: opts.include_entry_exports,
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,
output: opts.output,
},
retain_modules_for_health,
defer_performance: false,
analysis_snapshot,
}) {
Ok(mut result) => {
if let Some(changed_files) = changed_files {
fallow_engine::changed_files::filter_results_by_changed_files(
&mut result.results,
changed_files,
);
}
Ok(Some(result))
}
Err(code) => Err(code),
}
}
fn run_audit_dupes<'a>(
opts: &'a AuditOptions<'a>,
changed_since: Option<&'a str>,
changed_files: Option<&'a FxHashSet<PathBuf>>,
pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
) -> Result<Option<DupesResult>, ExitCode> {
let dupes_cfg = crate::load_config_for_analysis(
opts.root,
opts.config_path,
crate::ConfigLoadOptions {
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
production_override: opts
.production_dupes
.or_else(|| opts.production.then_some(true)),
quiet: opts.quiet,
allow_remote_extends: opts.allow_remote_extends,
},
fallow_config::ProductionAnalysis::Dupes,
)?
.duplicates;
let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
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 build_audit_dupes_options<'a>(
opts: &'a AuditOptions<'a>,
changed_since: Option<&'a str>,
changed_files: Option<&'a FxHashSet<PathBuf>>,
dupes_cfg: &fallow_config::DuplicatesConfig,
) -> DupesOptions<'a> {
DupesOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
json_style: opts.json_style,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
allow_remote_extends: opts.allow_remote_extends,
mode: Some(DupesMode::from(dupes_cfg.mode)),
near: dupes_cfg.near,
min_tokens: Some(dupes_cfg.min_tokens),
min_lines: Some(dupes_cfg.min_lines),
min_occurrences: Some(dupes_cfg.min_occurrences),
threshold: Some(dupes_cfg.threshold),
skip_local: dupes_cfg.skip_local,
cross_language: dupes_cfg.cross_language,
ignore_imports: Some(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,
diff_index: None,
use_shared_diff_index: true,
changed_files,
workspace: opts.workspace,
changed_workspaces: opts.changed_workspaces,
explain: opts.explain,
explain_skipped: opts.explain_skipped,
summary: false,
group_by: opts.group_by,
performance: false,
}
}
fn run_audit_health<'a>(
opts: &'a AuditOptions<'a>,
changed_since: Option<&'a str>,
shared_parse: Option<fallow_engine::health::HealthSharedParseData>,
) -> Result<Option<HealthResult>, ExitCode> {
let runtime_coverage = match opts.runtime_coverage {
Some(path) => Some(crate::health::coverage::prepare_options(
path,
opts.min_invocations_hot,
None,
None,
opts.output,
)?),
None => None,
};
let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
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),
}
}
fn build_audit_health_options<'a>(
opts: &'a AuditOptions<'a>,
changed_since: Option<&'a str>,
runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
) -> HealthOptions<'a> {
HealthOptions {
root: opts.root,
config_path: opts.config_path,
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: opts.quiet,
thresholds: fallow_engine::health::HealthThresholdOverrides {
max_cyclomatic: None,
max_cognitive: None,
max_crap: opts.max_crap,
},
top: None,
sort: fallow_engine::health::HealthSort::Cyclomatic,
production: opts.production_health.unwrap_or(opts.production),
production_override: opts.production_health,
allow_remote_extends: opts.allow_remote_extends,
changed_since,
diff_index: None,
use_shared_diff_index: true,
workspace: opts.workspace,
changed_workspaces: opts.changed_workspaces,
baseline: opts.health_baseline,
save_baseline: None,
baseline_mode: opts.health_baseline_mode,
baseline_mode_explicit: false,
complexity: true,
file_scores: false,
coverage_gaps: false,
config_activates_coverage_gaps: false,
hotspots: false,
ownership: false,
ownership_emails: None,
targets: false,
css: opts.css,
css_deep: opts.css_deep,
force_full: false,
score_only_output: false,
enforce_coverage_gap_gate: false,
effort: None,
score: false,
gates: fallow_engine::health::HealthGateOptions::default(),
since: None,
min_commits: None,
explain: opts.explain,
summary: false,
save_snapshot: None,
trend: false,
coverage_inputs: fallow_engine::health::HealthCoverageInputs {
coverage: opts.coverage,
coverage_root: opts.coverage_root,
},
performance: opts.performance,
runtime_coverage,
churn_file: None,
analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
complexity_breakdown: false,
group_by: opts.group_by.map(Into::into),
}
}
#[path = "audit_output.rs"]
mod output;
pub use output::audit_json_header_input;
pub use output::{
insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
print_audit_findings, print_audit_result, print_audit_result_with_style,
};
pub fn run_audit_with_type_aware(
opts: &AuditOptions<'_>,
gate_marker: Option<&str>,
type_aware: AuditTypeAwareOptions<'_>,
) -> ExitCode {
if let Err(e) = fallow_engine::health::validate_coverage_root_absolute(opts.coverage_root) {
return crate::error::emit_error_with_style(&e, 2, opts.output, opts.json_style);
}
let coverage_resolved = opts
.coverage
.map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
let runtime_coverage_resolved = opts
.runtime_coverage
.map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
let resolved_opts = AuditOptions {
coverage: coverage_resolved.as_deref(),
runtime_coverage: runtime_coverage_resolved.as_deref(),
..*opts
};
match execute_audit_with_type_aware(&resolved_opts, type_aware) {
Ok(result) => {
let _ = record_audit_impact(opts, gate_marker, &result);
let report_exit = print_audit_command_result(opts, &result, opts.json_style);
if report_exit == ExitCode::SUCCESS && audit_type_aware_completeness_failed(&result) {
ExitCode::from(1)
} else {
report_exit
}
}
Err(code) => code,
}
}
fn audit_type_aware_completeness_failed(result: &AuditResult) -> bool {
type_aware_meta_completeness_failed(
result
.check
.as_ref()
.and_then(|check| check.type_aware_meta.as_ref()),
)
}
fn type_aware_meta_completeness_failed(
meta: Option<&fallow_types::envelope::TypeAwareMeta>,
) -> bool {
crate::report::ci::required_type_aware_incomplete(meta)
}
fn record_audit_impact(
opts: &AuditOptions<'_>,
gate_marker: Option<&str>,
result: &AuditResult,
) -> Result<(), String> {
let mut findings = result
.check
.as_ref()
.map(|c| crate::impact::collect_dead_code_findings(&c.results))
.unwrap_or_default();
if let Some(health) = result.health.as_ref() {
findings.extend(crate::impact::collect_complexity_findings(&health.report));
}
let clones = result
.dupes
.as_ref()
.map(|d| crate::impact::collect_clone_findings(&d.report))
.unwrap_or_default();
let empty_supps: Vec<fallow_types::results::ActiveSuppression> = Vec::new();
let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
c.results.active_suppressions.as_slice()
});
let attribution = crate::impact::AttributionInput {
root: opts.root,
scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
findings,
clones,
suppressions,
};
let analysis_identity = result
.check
.as_ref()
.and_then(|check| check.type_aware_meta.as_ref())
.and_then(|meta| meta.identity.clone())
.unwrap_or_default();
crate::impact::record_audit_run(
opts.root,
&result.summary,
&crate::impact::AuditRunRecord {
verdict: result.verdict,
gate: gate_marker.is_some(),
git_sha: result.head_sha.as_deref(),
version: env!("CARGO_PKG_VERSION"),
timestamp: &crate::vital_signs::chrono_timestamp(),
attribution: Some(&attribution),
analysis_identity: &analysis_identity,
},
)
}
fn print_audit_command_result(
opts: &AuditOptions<'_>,
result: &AuditResult,
json_style: crate::json_style::JsonStyle,
) -> ExitCode {
if opts.walkthrough_guide {
return crate::audit_brief::print_walkthrough_guide_result(result, json_style);
}
if opts.walkthrough {
return crate::audit_brief::print_walkthrough_human_result(
result,
opts.root,
opts.cache_dir,
opts.mark_viewed,
opts.show_cleared,
opts.quiet,
json_style,
);
}
if let Some(path) = opts.walkthrough_file {
return crate::audit_brief::print_walkthrough_file_result(result, path, json_style);
}
if opts.brief {
return crate::audit_brief::print_brief_result(
result,
result.diff_index.as_ref(),
opts.quiet,
opts.explain,
opts.show_deprioritized,
json_style,
);
}
print_audit_result_with_style(result, opts.quiet, opts.explain, json_style)
}
#[must_use]
pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
let brief_opts = AuditOptions {
brief: true,
..*opts
};
match execute_audit(&brief_opts) {
Ok(result) => {
crate::audit_brief::print_decision_surface_result(&result, opts.quiet, opts.json_style)
}
Err(code) => code,
}
}
#[cfg(test)]
#[path = "audit_tests.rs"]
mod tests;