1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitCode};
4use std::time::{Duration, Instant};
5
6use fallow_config::{AuditGate, OutputFormat};
7use fallow_engine::clear_ambient_git_env;
8use rustc_hash::{FxHashMap, FxHashSet};
9use xxhash_rust::xxh3::xxh3_64;
10
11pub use fallow_api::{AuditAttribution, AuditSummary, AuditVerdict};
12
13#[cfg(test)]
14use crate::base_worktree::git_rev_parse;
15use crate::base_worktree::{
16 BaseWorktree, git_toplevel, resolve_cache_max_age, sweep_old_reusable_caches,
17};
18use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
19use crate::dupes::{DupesMode, DupesOptions, DupesResult};
20use crate::error::emit_error;
21use crate::health::{HealthOptions, HealthResult};
22
23pub struct AuditResult {
25 pub verdict: AuditVerdict,
26 pub summary: AuditSummary,
27 pub attribution: AuditAttribution,
28 pub base_snapshot: Option<AuditKeySnapshot>,
33 pub base_snapshot_skipped: bool,
34 pub changed_files_count: usize,
35 pub changed_files: Vec<PathBuf>,
39 pub base_ref: String,
40 pub base_description: Option<String>,
45 pub head_sha: Option<String>,
46 pub output: OutputFormat,
47 pub performance: bool,
48 pub check: Option<CheckResult>,
49 pub dupes: Option<DupesResult>,
50 pub health: Option<HealthResult>,
51 pub elapsed: Duration,
52 pub review_deltas: Option<crate::audit_brief::ReviewDeltas>,
56 pub weakening_signals: Vec<weakening::WeakeningSignal>,
57 pub routing: Option<routing::RoutingFacts>,
58 pub decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
62 pub graph_snapshot_hash: Option<String>,
69 pub change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
75}
76
77pub struct AuditOptions<'a> {
78 pub root: &'a std::path::Path,
79 pub config_path: &'a Option<std::path::PathBuf>,
80 pub cache_dir: &'a std::path::Path,
81 pub output: OutputFormat,
82 pub no_cache: bool,
83 pub threads: usize,
84 pub quiet: bool,
85 pub changed_since: Option<&'a str>,
86 pub production: bool,
87 pub production_dead_code: Option<bool>,
88 pub production_health: Option<bool>,
89 pub production_dupes: Option<bool>,
90 pub workspace: Option<&'a [String]>,
91 pub changed_workspaces: Option<&'a str>,
92 pub explain: bool,
93 pub explain_skipped: bool,
94 pub performance: bool,
95 pub group_by: Option<crate::GroupBy>,
96 pub dead_code_baseline: Option<&'a std::path::Path>,
98 pub health_baseline: Option<&'a std::path::Path>,
100 pub dupes_baseline: Option<&'a std::path::Path>,
102 pub max_crap: Option<f64>,
105 pub coverage: Option<&'a std::path::Path>,
107 pub coverage_root: Option<&'a std::path::Path>,
109 pub gate: AuditGate,
110 pub include_entry_exports: bool,
112 pub runtime_coverage: Option<&'a std::path::Path>,
118 pub min_invocations_hot: u64,
120 pub brief: bool,
125 pub max_decisions: usize,
129 pub walkthrough_guide: bool,
132 pub walkthrough: bool,
135 pub mark_viewed: &'a [std::path::PathBuf],
138 pub show_cleared: bool,
140 pub walkthrough_file: Option<&'a std::path::Path>,
143 pub show_deprioritized: bool,
148}
149
150#[path = "audit_base_ref.rs"]
151mod base_ref;
152#[path = "audit_cache.rs"]
153mod cache;
154
155#[cfg(test)]
156use base_ref::{auto_detect_base_ref, parse_audit_base_override};
157use base_ref::{get_head_sha, resolve_base_ref};
158#[cfg(test)]
159use cache::{
160 AUDIT_BASE_SNAPSHOT_CACHE_VERSION, CachedAuditKeySnapshot, audit_base_snapshot_cache_dir,
161 audit_base_snapshot_cache_file, cached_from_snapshot, config_file_fingerprint,
162 ensure_audit_base_snapshot_cache_dir, snapshot_from_cached,
163};
164use cache::{
165 AuditBaseSnapshotCacheKey, audit_base_snapshot_cache_key, load_cached_base_snapshot,
166 save_cached_base_snapshot, sorted_keys,
167};
168
169fn compute_verdict(
170 check: Option<&CheckResult>,
171 dupes: Option<&DupesResult>,
172 health: Option<&HealthResult>,
173) -> AuditVerdict {
174 let mut has_errors = false;
175 let mut has_warnings = false;
176
177 if let Some(result) = check {
178 if crate::check::has_error_severity_issues(
179 &result.results,
180 &result.config.rules,
181 Some(&result.config),
182 ) {
183 has_errors = true;
184 } else if result.results.total_issues() > 0 {
185 has_warnings = true;
186 }
187 }
188
189 if let Some(result) = health
190 && !result.report.findings.is_empty()
191 {
192 has_errors = true;
193 }
194
195 if let Some(result) = dupes
196 && !result.report.clone_groups.is_empty()
197 {
198 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
199 has_errors = true;
200 } else {
201 has_warnings = true;
202 }
203 }
204
205 if has_errors {
206 AuditVerdict::Fail
207 } else if has_warnings {
208 AuditVerdict::Warn
209 } else {
210 AuditVerdict::Pass
211 }
212}
213
214fn build_summary(
215 check: Option<&CheckResult>,
216 dupes: Option<&DupesResult>,
217 health: Option<&HealthResult>,
218) -> AuditSummary {
219 let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
220 let dead_code_has_errors = check.is_some_and(|r| {
221 crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
222 });
223 let complexity_findings = health.map_or(0, |r| r.report.findings.len());
224 let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
225 let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
226
227 AuditSummary {
228 dead_code_issues,
229 dead_code_has_errors,
230 complexity_findings,
231 max_cyclomatic,
232 duplication_clone_groups,
233 }
234}
235
236fn compute_audit_attribution(
237 check: Option<&CheckResult>,
238 dupes: Option<&DupesResult>,
239 health: Option<&HealthResult>,
240 base: Option<&AuditKeySnapshot>,
241 gate: AuditGate,
242) -> AuditAttribution {
243 let dead_code = check
244 .map(|r| {
245 count_introduced(
246 &dead_code_keys(&r.results, &r.config.root),
247 base.map(|b| &b.dead_code),
248 )
249 })
250 .unwrap_or_default();
251 let complexity = health
252 .map(|r| {
253 count_introduced(
254 &health_keys(&r.report, &r.config.root),
255 base.map(|b| &b.health),
256 )
257 })
258 .unwrap_or_default();
259 let duplication = dupes
260 .map(|r| {
261 count_introduced(
262 &dupes_keys(&r.report, &r.config.root),
263 base.map(|b| &b.dupes),
264 )
265 })
266 .unwrap_or_default();
267
268 AuditAttribution {
269 gate,
270 dead_code_introduced: dead_code.0,
271 dead_code_inherited: dead_code.1,
272 complexity_introduced: complexity.0,
273 complexity_inherited: complexity.1,
274 duplication_introduced: duplication.0,
275 duplication_inherited: duplication.1,
276 }
277}
278
279fn compute_introduced_verdict(
280 check: Option<&CheckResult>,
281 dupes: Option<&DupesResult>,
282 health: Option<&HealthResult>,
283 base: Option<&AuditKeySnapshot>,
284) -> AuditVerdict {
285 let mut has_errors = false;
286 let mut has_warnings = false;
287
288 if let Some(result) = check {
289 let (errors, warnings) = introduced_check_verdict(result, base);
290 has_errors |= errors;
291 has_warnings |= warnings;
292 }
293 if let Some(result) = health {
294 has_errors |= introduced_health_has_errors(result, base);
295 }
296 if let Some(result) = dupes {
297 let (errors, warnings) = introduced_dupes_verdict(result, base);
298 has_errors |= errors;
299 has_warnings |= warnings;
300 }
301
302 if has_errors {
303 AuditVerdict::Fail
304 } else if has_warnings {
305 AuditVerdict::Warn
306 } else {
307 AuditVerdict::Pass
308 }
309}
310
311fn introduced_check_verdict(result: &CheckResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
313 let base_keys = base.map(|b| &b.dead_code);
314 let mut introduced = result.results.clone();
315 retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
316 if crate::check::has_error_severity_issues(
317 &introduced,
318 &result.config.rules,
319 Some(&result.config),
320 ) {
321 (true, false)
322 } else if introduced.total_issues() > 0 {
323 (false, true)
324 } else {
325 (false, false)
326 }
327}
328
329fn introduced_health_has_errors(result: &HealthResult, base: Option<&AuditKeySnapshot>) -> bool {
331 let base_keys = base.map(|b| &b.health);
332 result.report.findings.iter().any(|finding| {
333 !base_keys
334 .is_some_and(|keys| keys.contains(&health_finding_key(finding, &result.config.root)))
335 })
336}
337
338fn introduced_dupes_verdict(result: &DupesResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
340 let base_keys = base.map(|b| &b.dupes);
341 let introduced = result
342 .report
343 .clone_groups
344 .iter()
345 .filter(|group| {
346 !base_keys
347 .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
348 })
349 .count();
350 if introduced == 0 {
351 return (false, false);
352 }
353 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
354 (true, false)
355 } else {
356 (false, true)
357 }
358}
359
360pub struct AuditKeySnapshot {
361 dead_code: FxHashSet<String>,
362 health: FxHashSet<String>,
363 dupes: FxHashSet<String>,
364 boundary_edges: FxHashSet<String>,
368 cycles: FxHashSet<String>,
370 public_api: FxHashSet<String>,
373}
374
375fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
376 let Some(base) = base else {
377 return (0, 0);
378 };
379 keys.iter().fold((0, 0), |(introduced, inherited), key| {
380 if base.contains(key) {
381 (introduced, inherited + 1)
382 } else {
383 (introduced + 1, inherited)
384 }
385 })
386}
387
388fn ambient_git_env_hint() -> Option<String> {
393 use fallow_engine::AMBIENT_GIT_ENV_VARS;
394 for var in AMBIENT_GIT_ENV_VARS {
395 if let Ok(value) = std::env::var(var)
396 && !value.is_empty()
397 {
398 return Some(format!(
399 "{var}={value} is set in the environment; if fallow is being \
400invoked from a git hook this can interfere with worktree operations. Re-run \
401with `env -u {var} fallow audit` to confirm."
402 ));
403 }
404 }
405 None
406}
407
408fn compute_base_snapshot(
409 opts: &AuditOptions<'_>,
410 base_ref: &str,
411 changed_files: &FxHashSet<PathBuf>,
412 base_sha: Option<&str>,
413) -> Result<AuditKeySnapshot, ExitCode> {
414 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
415 use std::fmt::Write as _;
416 let mut message =
417 format!("could not create a temporary worktree for base ref '{base_ref}'");
418 if let Some(hint) = ambient_git_env_hint() {
419 let _ = write!(message, "\n hint: {hint}");
420 }
421 return Err(emit_error(&message, 2, opts.output));
422 };
423 let base_root = base_analysis_root(opts.root, worktree.path());
424 let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
425 let current_config_path = opts
426 .config_path
427 .clone()
428 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
429 let base_opts =
430 build_base_audit_options(opts, &base_root, ¤t_config_path, &base_cache_dir);
431
432 let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
433 let check_production = opts.production_dead_code.unwrap_or(opts.production);
434 let health_production = opts.production_health.unwrap_or(opts.production);
435 let share_dead_code_parse_with_health = check_production == health_production;
436
437 let (check_res, dupes_res) = rayon::join(
438 || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
439 || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
440 );
441 let mut check = check_res?;
442 let dupes = dupes_res?;
443 let base_public_api = if opts.brief {
447 public_api_keys_from_check(check.as_ref(), &base_root)
448 } else {
449 FxHashSet::default()
450 };
451 let shared_parse = if share_dead_code_parse_with_health {
452 check.as_mut().and_then(|r| r.shared_parse.take())
453 } else {
454 None
455 };
456 let health = run_audit_health(&base_opts, None, shared_parse)?;
457 if let Some(ref mut check) = check {
458 check.shared_parse = None;
459 }
460
461 Ok(snapshot_from_results(
462 check.as_ref(),
463 dupes.as_ref(),
464 health.as_ref(),
465 base_public_api,
466 ))
467}
468
469fn snapshot_from_results(
475 check: Option<&CheckResult>,
476 dupes: Option<&DupesResult>,
477 health: Option<&HealthResult>,
478 public_api: FxHashSet<String>,
479) -> AuditKeySnapshot {
480 let (boundary_edges, cycles) = check.map_or_else(
481 || (FxHashSet::default(), FxHashSet::default()),
482 |r| {
483 (
484 review_deltas::boundary_edge_keys(&r.results.boundary_violations),
485 review_deltas::cycle_keys(&r.results.circular_dependencies, &r.config.root),
486 )
487 },
488 );
489 AuditKeySnapshot {
490 dead_code: check.map_or_else(FxHashSet::default, |r| {
491 dead_code_keys(&r.results, &r.config.root)
492 }),
493 health: health.map_or_else(FxHashSet::default, |r| {
494 health_keys(&r.report, &r.config.root)
495 }),
496 dupes: dupes.map_or_else(FxHashSet::default, |r| {
497 dupes_keys(&r.report, &r.config.root)
498 }),
499 boundary_edges,
500 cycles,
501 public_api,
502 }
503}
504
505fn public_api_keys_from_check(check: Option<&CheckResult>, root: &Path) -> FxHashSet<String> {
510 let Some(check) = check else {
511 return FxHashSet::default();
512 };
513 let Some(graph) = check
514 .shared_parse
515 .as_ref()
516 .and_then(|sp| sp.analysis_output.as_ref())
517 .and_then(|out| out.graph.as_ref())
518 else {
519 return FxHashSet::default();
520 };
521 let root_pkg = fallow_config::PackageJson::load(&check.config.root.join("package.json")).ok();
522 let workspaces = fallow_config::discover_workspaces(&check.config.root);
523 review_deltas::public_export_keys_for(
524 graph,
525 &check.config,
526 root_pkg.as_ref(),
527 &workspaces,
528 root,
529 )
530}
531
532#[expect(
534 clippy::ref_option,
535 reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
536)]
537fn build_base_audit_options<'a>(
538 opts: &AuditOptions<'a>,
539 base_root: &'a Path,
540 current_config_path: &'a Option<PathBuf>,
541 base_cache_dir: &'a Path,
542) -> AuditOptions<'a> {
543 AuditOptions {
544 root: base_root,
545 config_path: current_config_path,
546 cache_dir: base_cache_dir,
547 output: opts.output,
548 no_cache: opts.no_cache,
549 threads: opts.threads,
550 quiet: true,
551 changed_since: None,
552 production: opts.production,
553 production_dead_code: opts.production_dead_code,
554 production_health: opts.production_health,
555 production_dupes: opts.production_dupes,
556 workspace: opts.workspace,
557 changed_workspaces: None,
558 explain: false,
559 explain_skipped: false,
560 performance: false,
561 group_by: opts.group_by,
562 dead_code_baseline: None,
563 health_baseline: None,
564 dupes_baseline: None,
565 max_crap: opts.max_crap,
566 coverage: opts.coverage,
567 coverage_root: opts.coverage_root,
568 gate: AuditGate::All,
569 include_entry_exports: opts.include_entry_exports,
570 runtime_coverage: None,
571 min_invocations_hot: opts.min_invocations_hot,
572 brief: false,
573 max_decisions: 4,
574 walkthrough_guide: false,
575 walkthrough: false,
576 mark_viewed: &[],
577 show_cleared: false,
578 walkthrough_file: None,
579 show_deprioritized: false,
580 }
581}
582
583fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
584 let Some(git_root) = git_toplevel(current_root) else {
585 return base_worktree_root.to_path_buf();
586 };
587 let current_root =
588 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
589 match current_root.strip_prefix(&git_root) {
590 Ok(relative) => base_worktree_root.join(relative),
591 Err(err) => {
592 tracing::warn!(
593 current_root = %current_root.display(),
594 git_root = %git_root.display(),
595 error = %err,
596 "Could not remap audit base root into the base worktree; falling back to worktree root"
597 );
598 base_worktree_root.to_path_buf()
599 }
600 }
601}
602
603fn current_keys_as_base_keys(
604 check: Option<&CheckResult>,
605 dupes: Option<&DupesResult>,
606 health: Option<&HealthResult>,
607) -> AuditKeySnapshot {
608 let public_api = check
613 .and_then(|r| r.public_api_keys.clone())
614 .unwrap_or_default();
615 snapshot_from_results(check, dupes, health, public_api)
616}
617
618fn can_reuse_current_as_base(
619 opts: &AuditOptions<'_>,
620 base_ref: &str,
621 changed_files: &FxHashSet<PathBuf>,
622) -> bool {
623 let Some(git_root) = git_toplevel(opts.root) else {
624 return false;
625 };
626 let cache_dir = opts.cache_dir.to_path_buf();
627 let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
628 let mut reader: Option<BaseFileReader> = None;
631 for path in changed_files {
632 if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
633 continue;
634 }
635 if !is_analysis_input(path) {
636 if is_non_behavioral_doc(path) {
637 continue;
638 }
639 return false;
640 }
641 let Ok(current) = std::fs::read_to_string(path) else {
642 return false;
643 };
644 let Ok(relative) = path.strip_prefix(&git_root) else {
645 return false;
646 };
647 let reader = match reader.as_mut() {
648 Some(reader) => reader,
649 None => {
650 let Some(spawned) = BaseFileReader::spawn(opts.root) else {
651 return false;
652 };
653 reader.insert(spawned)
654 }
655 };
656 let Some(base) = reader.read(base_ref, relative) else {
657 return false;
658 };
659 if current == base {
660 continue;
661 }
662 if !js_ts_tokens_equivalent(path, ¤t, &base) {
663 return false;
664 }
665 }
666 true
667}
668
669struct BaseFileReader {
682 child: Option<crate::signal::ScopedChild>,
686 stdin: Option<std::process::ChildStdin>,
689 stdout: std::io::BufReader<std::process::ChildStdout>,
690}
691
692impl BaseFileReader {
693 fn spawn(root: &Path) -> Option<Self> {
699 let mut command = Command::new("git");
700 command
701 .args(["cat-file", "--batch"])
702 .current_dir(root)
703 .stdin(std::process::Stdio::piped())
704 .stdout(std::process::Stdio::piped())
705 .stderr(std::process::Stdio::null());
706 clear_ambient_git_env(&mut command);
707 let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
708 let stdin = child.take_stdin()?;
709 let stdout = child.take_stdout()?;
710 Some(Self {
711 child: Some(child),
712 stdin: Some(stdin),
713 stdout: std::io::BufReader::new(stdout),
714 })
715 }
716
717 fn read(&mut self, base_ref: &str, relative: &Path) -> Option<String> {
724 use std::io::{BufRead, Read};
725
726 let relative = relative.to_string_lossy().replace('\\', "/");
727 if relative.contains('\n') {
730 return None;
731 }
732
733 let stdin = self.stdin.as_mut()?;
734 writeln!(stdin, "{base_ref}:{relative}").ok()?;
735 stdin.flush().ok()?;
736
737 let mut header = String::new();
738 if self.stdout.read_line(&mut header).ok()? == 0 {
739 return None;
740 }
741 if header.trim_end().ends_with(" missing") {
743 return None;
744 }
745 let size: usize = header.trim_end().rsplit(' ').next()?.parse().ok()?;
747 let mut buf = vec![0u8; size];
748 self.stdout.read_exact(&mut buf).ok()?;
749 let mut newline = [0u8; 1];
752 self.stdout.read_exact(&mut newline).ok()?;
753
754 Some(String::from_utf8_lossy(&buf).into_owned())
755 }
756}
757
758impl Drop for BaseFileReader {
759 fn drop(&mut self) {
760 self.stdin.take();
765 if let Some(child) = self.child.take() {
766 let _ = child.wait();
767 }
768 }
769}
770
771fn is_fallow_cache_artifact(
772 path: &Path,
773 cache_dir: &Path,
774 canonical_cache_dir: Option<&Path>,
775) -> bool {
776 path.starts_with(cache_dir)
777 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
778}
779
780fn remap_cache_dir_for_base_worktree(
781 current_root: &Path,
782 base_worktree_root: &Path,
783 cache_dir: &Path,
784) -> PathBuf {
785 if cache_dir.is_absolute()
786 && let Ok(relative) = cache_dir.strip_prefix(current_root)
787 {
788 return base_worktree_root.join(relative);
789 }
790 cache_dir.to_path_buf()
791}
792
793fn is_analysis_input(path: &Path) -> bool {
794 matches!(
795 path.extension().and_then(|ext| ext.to_str()),
796 Some(
797 "js" | "jsx"
798 | "ts"
799 | "tsx"
800 | "mjs"
801 | "mts"
802 | "cjs"
803 | "cts"
804 | "vue"
805 | "svelte"
806 | "astro"
807 | "mdx"
808 | "css"
809 | "scss"
810 )
811 )
812}
813
814fn is_non_behavioral_doc(path: &Path) -> bool {
815 matches!(
816 path.extension().and_then(|ext| ext.to_str()),
817 Some("md" | "markdown" | "txt" | "rst" | "adoc")
818 )
819}
820
821fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
822 if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
823 return false;
824 }
825 if !matches!(
826 path.extension().and_then(|ext| ext.to_str()),
827 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
828 ) {
829 return false;
830 }
831 fallow_engine::source_token_kinds_equivalent(path, current, base, false)
832}
833
834fn remap_focus_files(
835 files: &FxHashSet<PathBuf>,
836 from_root: &Path,
837 to_root: &Path,
838) -> Option<FxHashSet<PathBuf>> {
839 let mut remapped = FxHashSet::default();
840 for file in files {
841 if let Ok(relative) = file.strip_prefix(from_root) {
842 remapped.insert(to_root.join(relative));
843 }
844 }
845 if remapped.is_empty() {
846 return None;
847 }
848 Some(remapped)
849}
850
851#[cfg(test)]
852use std::time::SystemTime;
853
854#[cfg(test)]
855use crate::base_worktree::{
856 ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
857 is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
858 materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
859 remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
860 sweep_orphan_audit_worktrees, touch_last_used,
861};
862
863pub use fallow_api::audit_keys as keys;
864
865#[path = "audit_review_deltas.rs"]
866pub mod review_deltas;
867
868#[path = "audit_weakening.rs"]
869pub mod weakening;
870
871#[path = "audit_routing.rs"]
872pub mod routing;
873
874use keys::{
875 dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
876 retain_introduced_dead_code,
877};
878
879struct HeadAnalyses {
880 check: Option<CheckResult>,
881 dupes: Option<DupesResult>,
882 health: Option<HealthResult>,
883}
884
885type HeadAndBaseResult = (
888 Result<HeadAnalyses, ExitCode>,
889 Option<Result<AuditKeySnapshot, ExitCode>>,
890);
891
892fn run_audit_head_and_base(
895 opts: &AuditOptions<'_>,
896 changed_since: Option<&str>,
897 changed_files: &FxHashSet<PathBuf>,
898 base_ref: &str,
899 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
900 run_base: bool,
901) -> HeadAndBaseResult {
902 if run_base {
903 let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
904 let (h, b) = rayon::join(
905 || run_audit_head_analyses(opts, changed_since, changed_files),
906 || compute_base_snapshot(opts, base_ref, changed_files, base_sha),
907 );
908 (h, Some(b))
909 } else {
910 (
911 run_audit_head_analyses(opts, changed_since, changed_files),
912 None,
913 )
914 }
915}
916
917struct AuditResultParts {
918 verdict: AuditVerdict,
919 summary: AuditSummary,
920 attribution: AuditAttribution,
921 base_snapshot: Option<AuditKeySnapshot>,
922 base_snapshot_skipped: bool,
923 changed_files_count: usize,
924 changed_files: FxHashSet<PathBuf>,
925 base_ref: String,
926 base_description: Option<String>,
927 head_sha: Option<String>,
928 output: OutputFormat,
929 performance: bool,
930 check: Option<CheckResult>,
931 dupes: Option<DupesResult>,
932 health: Option<HealthResult>,
933 elapsed: Duration,
934 review_deltas: Option<crate::audit_brief::ReviewDeltas>,
935 weakening_signals: Vec<weakening::WeakeningSignal>,
936 routing: Option<routing::RoutingFacts>,
937 decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
938 graph_snapshot_hash: Option<String>,
939 change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
940}
941
942fn run_audit_head_analyses(
949 opts: &AuditOptions<'_>,
950 changed_since: Option<&str>,
951 changed_files: &FxHashSet<PathBuf>,
952) -> Result<HeadAnalyses, ExitCode> {
953 let check_production = opts.production_dead_code.unwrap_or(opts.production);
954 let health_production = opts.production_health.unwrap_or(opts.production);
955 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
956 let share_dead_code_parse_with_health = check_production == health_production;
957 let share_dead_code_files_with_dupes =
958 share_dead_code_parse_with_health && check_production == dupes_production;
959
960 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
961 let dupes_files = if share_dead_code_files_with_dupes {
962 check
963 .as_ref()
964 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
965 } else {
966 None
967 };
968 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
969 if opts.brief
974 && let Some(ref mut check) = check
975 {
976 check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
977 check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
978 check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
979 check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
980 check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
981 check.internal_consumers =
982 compute_brief_internal_consumers(opts.root, check, changed_files);
983 }
984 let shared_parse = if share_dead_code_parse_with_health {
985 check.as_mut().and_then(|r| r.shared_parse.take())
986 } else {
987 None
988 };
989 let health = run_audit_health(opts, changed_since, shared_parse)?;
990 Ok(HeadAnalyses {
991 check,
992 dupes,
993 health,
994 })
995}
996
997fn compute_brief_impact_closure(
1005 root: &std::path::Path,
1006 check: &CheckResult,
1007 changed_files: &FxHashSet<PathBuf>,
1008) -> Option<fallow_engine::ImpactClosurePaths> {
1009 let graph = check
1010 .shared_parse
1011 .as_ref()
1012 .and_then(|sp| sp.analysis_output.as_ref())
1013 .and_then(|out| out.graph.as_ref())?;
1014
1015 fallow_engine::impact_closure_for_changed_paths(graph, root, changed_files)
1016}
1017
1018fn compute_brief_partition_order(
1026 root: &std::path::Path,
1027 check: &CheckResult,
1028 changed_files: &FxHashSet<PathBuf>,
1029) -> Option<fallow_engine::PartitionOrderPaths> {
1030 let graph = check
1031 .shared_parse
1032 .as_ref()
1033 .and_then(|sp| sp.analysis_output.as_ref())
1034 .and_then(|out| out.graph.as_ref())?;
1035
1036 fallow_engine::partition_order_for_changed_paths(graph, root, changed_files)
1037}
1038
1039fn compute_brief_export_lines(
1044 root: &std::path::Path,
1045 check: &CheckResult,
1046 changed_files: &FxHashSet<PathBuf>,
1047) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
1048 let graph = check
1049 .shared_parse
1050 .as_ref()
1051 .and_then(|sp| sp.analysis_output.as_ref())
1052 .and_then(|out| out.graph.as_ref())?;
1053
1054 fallow_engine::export_lines_for_changed_paths(graph, root, changed_files)
1055}
1056
1057fn compute_brief_internal_consumers(
1066 root: &std::path::Path,
1067 check: &CheckResult,
1068 changed_files: &FxHashSet<PathBuf>,
1069) -> Option<FxHashMap<String, u64>> {
1070 let graph = check
1071 .shared_parse
1072 .as_ref()
1073 .and_then(|sp| sp.analysis_output.as_ref())
1074 .and_then(|out| out.graph.as_ref())?;
1075
1076 fallow_engine::internal_consumers_for_changed_paths(graph, root, changed_files)
1077}
1078
1079fn compute_brief_focus_facts(
1089 root: &std::path::Path,
1090 check: &CheckResult,
1091 changed_files: &FxHashSet<PathBuf>,
1092) -> Option<Vec<fallow_engine::FocusFileFactsPaths>> {
1093 let graph = check
1094 .shared_parse
1095 .as_ref()
1096 .and_then(|sp| sp.analysis_output.as_ref())
1097 .and_then(|out| out.graph.as_ref())?;
1098
1099 fallow_engine::focus_facts_for_changed_paths(graph, root, changed_files)
1100}
1101
1102pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1104 let start = Instant::now();
1105
1106 let (base_ref, base_description) = resolve_base_ref(opts)?;
1107
1108 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1109 return Err(emit_error(
1110 &format!(
1111 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1112 ),
1113 2,
1114 opts.output,
1115 ));
1116 };
1117 let changed_files_count = changed_files.len();
1118
1119 if changed_files.is_empty() {
1120 return Ok(empty_audit_result(
1121 base_ref,
1122 base_description,
1123 opts,
1124 start.elapsed(),
1125 ));
1126 }
1127
1128 sweep_old_reusable_caches(
1132 opts.root,
1133 resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1134 opts.quiet,
1135 );
1136
1137 let changed_since = Some(base_ref.as_str());
1138
1139 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1140 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1141 let base_cache_key = if needs_real_base_snapshot {
1142 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1143 } else {
1144 None
1145 };
1146 let cached_base_snapshot = base_cache_key
1147 .as_ref()
1148 .and_then(|key| load_cached_base_snapshot(opts, key));
1149
1150 let (head_res, base_res) = run_audit_head_and_base(
1151 opts,
1152 changed_since,
1153 &changed_files,
1154 &base_ref,
1155 base_cache_key.as_ref(),
1156 needs_real_base_snapshot && cached_base_snapshot.is_none(),
1157 );
1158
1159 assemble_audit_result(AuditAssemblyInput {
1160 opts,
1161 head_res,
1162 base_res,
1163 cached_base_snapshot,
1164 base_cache_key,
1165 changed_files,
1166 changed_files_count,
1167 base_ref,
1168 base_description,
1169 start,
1170 })
1171}
1172
1173struct AuditAssemblyInput<'a> {
1175 opts: &'a AuditOptions<'a>,
1176 head_res: Result<HeadAnalyses, ExitCode>,
1177 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1178 cached_base_snapshot: Option<AuditKeySnapshot>,
1179 base_cache_key: Option<AuditBaseSnapshotCacheKey>,
1180 changed_files: FxHashSet<PathBuf>,
1181 changed_files_count: usize,
1182 base_ref: String,
1183 base_description: Option<String>,
1184 start: Instant,
1185}
1186
1187fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
1190 let opts = input.opts;
1191 let head = input.head_res?;
1192 let mut check_result = head.check;
1193 let dupes_result = head.dupes;
1194 let health_result = head.health;
1195
1196 let (base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
1197 opts,
1198 input.cached_base_snapshot,
1199 input.base_res,
1200 input.base_cache_key.as_ref(),
1201 CurrentAnalysisRefs {
1202 check: check_result.as_ref(),
1203 dupes: dupes_result.as_ref(),
1204 health: health_result.as_ref(),
1205 },
1206 )?;
1207 if let Some(ref mut check) = check_result {
1208 check.shared_parse = None;
1209 }
1210 let (attribution, verdict, summary) = compute_audit_outcome(
1211 opts.gate,
1212 check_result.as_ref(),
1213 dupes_result.as_ref(),
1214 health_result.as_ref(),
1215 base_snapshot.as_ref(),
1216 );
1217
1218 let (review_deltas, weakening_signals, routing) = if opts.brief {
1221 compute_brief_e3_data(
1222 opts,
1223 check_result.as_ref(),
1224 base_snapshot.as_ref(),
1225 &input.changed_files,
1226 &input.base_ref,
1227 )
1228 } else {
1229 (None, Vec::new(), None)
1230 };
1231
1232 let decision_surface = if opts.brief {
1236 Some(compute_decision_surface(
1237 opts,
1238 check_result.as_ref(),
1239 review_deltas.as_ref(),
1240 routing.as_ref(),
1241 ))
1242 } else {
1243 None
1244 };
1245
1246 let change_anchors = if opts.brief {
1251 compute_change_anchors(opts.root, &input.base_ref)
1252 } else {
1253 Vec::new()
1254 };
1255
1256 let head_sha = get_head_sha(opts.root);
1262 let graph_snapshot_hash = if opts.brief {
1263 Some(compute_graph_snapshot_hash(
1264 check_result.as_ref(),
1265 dupes_result.as_ref(),
1266 health_result.as_ref(),
1267 &input.base_ref,
1268 head_sha.as_deref(),
1269 &change_anchors,
1270 ))
1271 } else {
1272 None
1273 };
1274
1275 Ok(build_audit_result(AuditResultParts {
1276 verdict,
1277 summary,
1278 attribution,
1279 base_snapshot,
1280 base_snapshot_skipped,
1281 changed_files_count: input.changed_files_count,
1282 changed_files: input.changed_files,
1283 base_ref: input.base_ref,
1284 base_description: input.base_description,
1285 head_sha,
1286 output: opts.output,
1287 performance: opts.performance,
1288 check: check_result,
1289 dupes: dupes_result,
1290 health: health_result,
1291 elapsed: input.start.elapsed(),
1292 review_deltas,
1293 weakening_signals,
1294 routing,
1295 decision_surface,
1296 graph_snapshot_hash,
1297 change_anchors,
1298 }))
1299}
1300
1301fn compute_graph_snapshot_hash(
1311 check: Option<&CheckResult>,
1312 dupes: Option<&DupesResult>,
1313 health: Option<&HealthResult>,
1314 base_ref: &str,
1315 head_sha: Option<&str>,
1316 change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
1317) -> String {
1318 let public_api = check
1322 .and_then(|c| c.public_api_keys.clone())
1323 .unwrap_or_default();
1324 let snapshot = snapshot_from_results(check, dupes, health, public_api);
1325 let mut bytes: Vec<u8> = Vec::new();
1326 for set in [
1328 &snapshot.dead_code,
1329 &snapshot.health,
1330 &snapshot.dupes,
1331 &snapshot.boundary_edges,
1332 &snapshot.cycles,
1333 &snapshot.public_api,
1334 ] {
1335 for key in sorted_keys(set) {
1336 bytes.extend_from_slice(key.as_bytes());
1337 bytes.push(0);
1338 }
1339 bytes.push(1);
1340 }
1341 let mut anchor_ids: Vec<&str> = change_anchors
1346 .iter()
1347 .map(|a| a.change_anchor.as_str())
1348 .collect();
1349 anchor_ids.sort_unstable();
1350 for id in anchor_ids {
1351 bytes.extend_from_slice(id.as_bytes());
1352 bytes.push(0);
1353 }
1354 bytes.push(1);
1355 bytes.extend_from_slice(base_ref.as_bytes());
1356 bytes.push(0);
1357 bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
1358 format!("graph:{:016x}", xxh3_64(&bytes))
1359}
1360
1361fn compute_change_anchors(
1369 root: &std::path::Path,
1370 base_ref: &str,
1371) -> Vec<crate::audit_walkthrough::ChangeAnchor> {
1372 if let Some(raw) = crate::report::ci::diff_filter::shared_diff_raw() {
1373 return crate::audit_walkthrough::parse_change_anchors(raw);
1374 }
1375 fallow_engine::try_get_changed_diff(root, base_ref)
1376 .map(|diff| crate::audit_walkthrough::parse_change_anchors(&diff))
1377 .unwrap_or_default()
1378}
1379
1380fn compute_decision_surface(
1386 opts: &AuditOptions<'_>,
1387 check: Option<&CheckResult>,
1388 review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
1389 routing: Option<&routing::RoutingFacts>,
1390) -> crate::audit_decision_surface::DecisionSurface {
1391 use crate::audit_decision_surface::{
1392 BoundaryAnchor, CoordinationAnchor, DecisionInputs, extract_decision_surface,
1393 };
1394
1395 let (Some(check), Some(deltas)) = (check, review_deltas) else {
1396 return crate::audit_decision_surface::DecisionSurface::default();
1397 };
1398 let root = &check.config.root;
1399
1400 let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
1404 let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
1405 for finding in &check.results.boundary_violations {
1406 let key = review_deltas::boundary_edge_key(finding);
1407 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
1408 continue;
1409 }
1410 boundary_anchors.push(BoundaryAnchor {
1411 zone_pair_key: key,
1412 from_file: keys::relative_key_path(&finding.violation.from_path, root),
1413 from_zone: finding.violation.from_zone.clone(),
1414 to_zone: finding.violation.to_zone.clone(),
1415 line: finding.violation.line,
1416 });
1417 }
1418
1419 let closure = check.impact_closure.as_ref();
1423 let mut coordination: Vec<CoordinationAnchor> = closure
1424 .map(|c| aggregate_coordination_gaps(&c.coordination_gap))
1425 .unwrap_or_default();
1426 let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
1427
1428 let empty_routing = routing::RoutingFacts::default();
1429 let routing = routing.unwrap_or(&empty_routing);
1430
1431 let root_owned = root.clone();
1435 let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
1436
1437 let export_lines = check.export_lines.as_ref();
1442 let resolve_line = |rel: &str, symbols: &[String]| -> u32 {
1443 let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
1444 return 0;
1445 };
1446 exports
1447 .iter()
1448 .find(|(name, _)| symbols.iter().any(|s| name == s))
1449 .or_else(|| exports.first())
1450 .map_or(0, |(_, line)| *line)
1451 };
1452 for anchor in &mut coordination {
1453 anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
1454 }
1455 let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
1456 let mut parts = key.splitn(2, "::");
1457 let path = parts.next().unwrap_or_default();
1458 let name = parts.next().unwrap_or_default();
1459 resolve_line(path, &[name.to_string()])
1460 });
1461
1462 let rename_old_path = |rel: &str| -> Option<String> {
1466 crate::report::ci::diff_filter::shared_diff_index()
1467 .and_then(|idx| idx.old_path_for(rel))
1468 .map(str::to_string)
1469 };
1470
1471 let internal_consumers_map = check.internal_consumers.as_ref();
1474 let internal_consumers = |rel: &str| -> u64 {
1475 internal_consumers_map
1476 .and_then(|map| map.get(rel))
1477 .copied()
1478 .unwrap_or(0)
1479 };
1480
1481 extract_decision_surface(&DecisionInputs {
1482 deltas,
1483 boundary_anchors: &boundary_anchors,
1484 coordination: &coordination,
1485 public_api_anchor_line,
1486 affected_not_shown,
1487 routing,
1488 head_source: &head_source,
1489 rename_old_path: &rename_old_path,
1490 internal_consumers: &internal_consumers,
1491 cap: opts.max_decisions,
1492 })
1493}
1494
1495fn aggregate_coordination_gaps(
1500 gaps: &[fallow_engine::CoordinationGapPaths],
1501) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
1502 use crate::audit_decision_surface::CoordinationAnchor;
1503 let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
1504 for gap in gaps {
1505 let entry = by_file
1506 .entry(gap.changed_file.clone())
1507 .or_insert_with(|| (0, FxHashSet::default()));
1508 entry.0 += 1;
1509 for symbol in &gap.consumed_symbols {
1510 entry.1.insert(symbol.clone());
1511 }
1512 }
1513 let mut anchors: Vec<CoordinationAnchor> = by_file
1514 .into_iter()
1515 .map(|(changed_file, (consumer_count, symbols))| {
1516 let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
1517 consumed_symbols.sort_unstable();
1518 CoordinationAnchor {
1519 changed_file,
1520 consumed_symbols,
1521 consumer_count,
1522 line: 0,
1523 }
1524 })
1525 .collect();
1526 anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
1527 anchors
1528}
1529
1530fn compute_brief_e3_data(
1535 opts: &AuditOptions<'_>,
1536 check: Option<&CheckResult>,
1537 base_snapshot: Option<&AuditKeySnapshot>,
1538 changed_files: &FxHashSet<PathBuf>,
1539 base_ref: &str,
1540) -> (
1541 Option<crate::audit_brief::ReviewDeltas>,
1542 Vec<weakening::WeakeningSignal>,
1543 Option<routing::RoutingFacts>,
1544) {
1545 let deltas = check.map(|check| {
1546 let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
1547 let head_cycles =
1548 review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
1549 let head_public_api = check.public_api_keys.clone().unwrap_or_default();
1550 let empty = FxHashSet::default();
1551 let (base_boundary, base_cycles, base_public_api) = base_snapshot
1552 .map_or((&empty, &empty, &empty), |b| {
1553 (&b.boundary_edges, &b.cycles, &b.public_api)
1554 });
1555 crate::audit_brief::build_review_deltas(
1556 &head_boundary,
1557 base_boundary,
1558 &head_cycles,
1559 base_cycles,
1560 &head_public_api,
1561 base_public_api,
1562 )
1563 });
1564
1565 let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
1566
1567 let routing =
1568 check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
1569
1570 (deltas, weakening_signals, routing)
1571}
1572
1573fn compute_weakening_signals(
1578 root: &Path,
1579 base_ref: &str,
1580 changed_files: &FxHashSet<PathBuf>,
1581) -> Vec<weakening::WeakeningSignal> {
1582 use weakening::{WeakeningKind, WeakeningSignal};
1583
1584 let Some(git_root) = git_toplevel(root) else {
1585 return Vec::new();
1586 };
1587 let Some(mut reader) = BaseFileReader::spawn(root) else {
1588 return Vec::new();
1589 };
1590
1591 let mut signals: Vec<WeakeningSignal> = Vec::new();
1592 let mut files: Vec<&PathBuf> = changed_files.iter().collect();
1594 files.sort();
1595
1596 for abs in files {
1597 let Ok(relative) = abs.strip_prefix(&git_root) else {
1598 continue;
1599 };
1600 let rel_str = relative.to_string_lossy().replace('\\', "/");
1601 let head = std::fs::read_to_string(abs).unwrap_or_default();
1602 let base = reader.read(base_ref, relative).unwrap_or_default();
1603 if weakening::is_test_file(&rel_str) {
1607 for token in weakening::detect_test_weakening(&base, &head) {
1608 signals.push(WeakeningSignal {
1609 kind: WeakeningKind::TestWeakened,
1610 file: rel_str.clone(),
1611 evidence: format!("{token} added"),
1612 });
1613 }
1614 for ev in weakening::detect_removed_tests(&base, &head) {
1615 signals.push(WeakeningSignal {
1616 kind: WeakeningKind::TestWeakened,
1617 file: rel_str.clone(),
1618 evidence: ev,
1619 });
1620 }
1621 }
1622 for ev in weakening::detect_added_suppressions(&base, &head) {
1623 signals.push(WeakeningSignal {
1624 kind: WeakeningKind::SuppressionAdded,
1625 file: rel_str.clone(),
1626 evidence: ev,
1627 });
1628 }
1629 for ev in weakening::detect_lowered_thresholds(&base, &head) {
1630 signals.push(WeakeningSignal {
1631 kind: WeakeningKind::ThresholdLowered,
1632 file: rel_str.clone(),
1633 evidence: ev,
1634 });
1635 }
1636 if weakening::is_ci_file(&rel_str) {
1637 for ev in weakening::detect_removed_security_steps(&base, &head) {
1638 signals.push(WeakeningSignal {
1639 kind: WeakeningKind::SecurityCheckRemoved,
1640 file: rel_str.clone(),
1641 evidence: ev,
1642 });
1643 }
1644 }
1645 }
1646 signals
1647}
1648
1649fn compute_audit_outcome(
1652 gate: AuditGate,
1653 check: Option<&CheckResult>,
1654 dupes: Option<&DupesResult>,
1655 health: Option<&HealthResult>,
1656 base: Option<&AuditKeySnapshot>,
1657) -> (AuditAttribution, AuditVerdict, AuditSummary) {
1658 let attribution = compute_audit_attribution(check, dupes, health, base, gate);
1659 let verdict = compute_audit_verdict(gate, check, dupes, health, base);
1660 let summary = build_summary(check, dupes, health);
1661 crate::telemetry::note_final_result_count(
1662 summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1663 );
1664 (attribution, verdict, summary)
1665}
1666
1667#[derive(Clone, Copy)]
1674struct CurrentAnalysisRefs<'a> {
1675 check: Option<&'a CheckResult>,
1676 dupes: Option<&'a DupesResult>,
1677 health: Option<&'a HealthResult>,
1678}
1679
1680fn resolve_base_snapshot(
1681 opts: &AuditOptions<'_>,
1682 cached_base_snapshot: Option<AuditKeySnapshot>,
1683 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1684 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
1685 current: CurrentAnalysisRefs<'_>,
1686) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
1687 if !matches!(opts.gate, AuditGate::NewOnly) {
1688 return Ok((None, false));
1689 }
1690 if let Some(snapshot) = cached_base_snapshot {
1691 return Ok((Some(snapshot), false));
1692 }
1693 if let Some(base_res) = base_res {
1694 let snapshot = base_res?;
1695 if let Some(key) = base_cache_key {
1696 save_cached_base_snapshot(opts, key, &snapshot);
1697 }
1698 return Ok((Some(snapshot), false));
1699 }
1700 let CurrentAnalysisRefs {
1701 check,
1702 dupes,
1703 health,
1704 } = current;
1705 Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
1706}
1707
1708fn compute_audit_verdict(
1711 gate: AuditGate,
1712 check: Option<&CheckResult>,
1713 dupes: Option<&DupesResult>,
1714 health: Option<&HealthResult>,
1715 base: Option<&AuditKeySnapshot>,
1716) -> AuditVerdict {
1717 if matches!(gate, AuditGate::NewOnly) {
1718 compute_introduced_verdict(check, dupes, health, base)
1719 } else {
1720 compute_verdict(check, dupes, health)
1721 }
1722}
1723
1724fn build_audit_result(parts: AuditResultParts) -> AuditResult {
1725 AuditResult {
1726 verdict: parts.verdict,
1727 summary: parts.summary,
1728 attribution: parts.attribution,
1729 base_snapshot: parts.base_snapshot,
1730 base_snapshot_skipped: parts.base_snapshot_skipped,
1731 changed_files_count: parts.changed_files_count,
1732 changed_files: parts.changed_files.into_iter().collect(),
1733 base_ref: parts.base_ref,
1734 base_description: parts.base_description,
1735 head_sha: parts.head_sha,
1736 output: parts.output,
1737 performance: parts.performance,
1738 check: parts.check,
1739 dupes: parts.dupes,
1740 health: parts.health,
1741 elapsed: parts.elapsed,
1742 review_deltas: parts.review_deltas,
1743 weakening_signals: parts.weakening_signals,
1744 routing: parts.routing,
1745 decision_surface: parts.decision_surface,
1746 graph_snapshot_hash: parts.graph_snapshot_hash,
1747 change_anchors: parts.change_anchors,
1748 }
1749}
1750
1751fn empty_audit_result(
1753 base_ref: String,
1754 base_description: Option<String>,
1755 opts: &AuditOptions<'_>,
1756 elapsed: Duration,
1757) -> AuditResult {
1758 crate::telemetry::note_final_result_count(0);
1759
1760 let head_sha = get_head_sha(opts.root);
1761 let graph_snapshot_hash = if opts.brief {
1765 Some(compute_graph_snapshot_hash(
1767 None,
1768 None,
1769 None,
1770 &base_ref,
1771 head_sha.as_deref(),
1772 &[],
1773 ))
1774 } else {
1775 None
1776 };
1777
1778 AuditResult {
1779 verdict: AuditVerdict::Pass,
1780 summary: AuditSummary {
1781 dead_code_issues: 0,
1782 dead_code_has_errors: false,
1783 complexity_findings: 0,
1784 max_cyclomatic: None,
1785 duplication_clone_groups: 0,
1786 },
1787 attribution: AuditAttribution {
1788 gate: opts.gate,
1789 ..AuditAttribution::default()
1790 },
1791 base_snapshot: None,
1792 base_snapshot_skipped: false,
1793 changed_files_count: 0,
1794 changed_files: Vec::new(),
1795 base_ref,
1796 base_description,
1797 head_sha,
1798 output: opts.output,
1799 performance: opts.performance,
1800 check: None,
1801 dupes: None,
1802 health: None,
1803 elapsed,
1804 review_deltas: None,
1805 weakening_signals: Vec::new(),
1806 routing: None,
1807 decision_surface: None,
1808 graph_snapshot_hash,
1809 change_anchors: Vec::new(),
1810 }
1811}
1812
1813fn run_audit_check<'a>(
1815 opts: &'a AuditOptions<'a>,
1816 changed_since: Option<&'a str>,
1817 retain_modules_for_health: bool,
1818) -> Result<Option<CheckResult>, ExitCode> {
1819 let filters = IssueFilters::default();
1820 let retain_modules_for_health = retain_modules_for_health || opts.brief;
1825 let trace_opts = TraceOptions {
1826 trace_export: None,
1827 trace_file: None,
1828 trace_dependency: None,
1829 impact_closure: None,
1830 performance: opts.performance,
1831 };
1832 match crate::check::execute_check(&CheckOptions {
1833 root: opts.root,
1834 config_path: opts.config_path,
1835 output: opts.output,
1836 no_cache: opts.no_cache,
1837 threads: opts.threads,
1838 quiet: opts.quiet,
1839 fail_on_issues: false,
1840 filters: &filters,
1841 changed_since,
1842 diff_index: None,
1843 use_shared_diff_index: true,
1844 baseline: opts.dead_code_baseline,
1845 save_baseline: None,
1846 sarif_file: None,
1847 production: opts.production_dead_code.unwrap_or(opts.production),
1848 production_override: opts.production_dead_code,
1849 workspace: opts.workspace,
1850 changed_workspaces: opts.changed_workspaces,
1851 group_by: opts.group_by,
1852 include_dupes: false,
1853 trace_opts: &trace_opts,
1854 explain: opts.explain,
1855 top: None,
1856 file: &[],
1857 include_entry_exports: opts.include_entry_exports,
1858 summary: false,
1859 regression_opts: crate::regression::RegressionOpts {
1860 fail_on_regression: false,
1861 tolerance: crate::regression::Tolerance::Absolute(0),
1862 regression_baseline_file: None,
1863 save_target: crate::regression::SaveRegressionTarget::None,
1864 scoped: true,
1865 quiet: opts.quiet,
1866 output: opts.output,
1867 },
1868 retain_modules_for_health,
1869 defer_performance: false,
1870 }) {
1871 Ok(r) => Ok(Some(r)),
1872 Err(code) => Err(code),
1873 }
1874}
1875
1876fn run_audit_dupes<'a>(
1882 opts: &'a AuditOptions<'a>,
1883 changed_since: Option<&'a str>,
1884 changed_files: Option<&'a FxHashSet<PathBuf>>,
1885 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1886) -> Result<Option<DupesResult>, ExitCode> {
1887 let dupes_cfg = match crate::load_config_for_analysis(
1888 opts.root,
1889 opts.config_path,
1890 crate::ConfigLoadOptions {
1891 output: opts.output,
1892 no_cache: opts.no_cache,
1893 threads: opts.threads,
1894 production_override: opts
1895 .production_dupes
1896 .or_else(|| opts.production.then_some(true)),
1897 quiet: opts.quiet,
1898 },
1899 fallow_config::ProductionAnalysis::Dupes,
1900 ) {
1901 Ok(c) => c.duplicates,
1902 Err(code) => return Err(code),
1903 };
1904 let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
1905 let dupes_run = if let Some(files) = pre_discovered {
1906 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1907 } else {
1908 crate::dupes::execute_dupes(&dupes_opts)
1909 };
1910 match dupes_run {
1911 Ok(r) => Ok(Some(r)),
1912 Err(code) => Err(code),
1913 }
1914}
1915
1916fn build_audit_dupes_options<'a>(
1918 opts: &'a AuditOptions<'a>,
1919 changed_since: Option<&'a str>,
1920 changed_files: Option<&'a FxHashSet<PathBuf>>,
1921 dupes_cfg: &fallow_config::DuplicatesConfig,
1922) -> DupesOptions<'a> {
1923 DupesOptions {
1924 root: opts.root,
1925 config_path: opts.config_path,
1926 output: opts.output,
1927 no_cache: opts.no_cache,
1928 threads: opts.threads,
1929 quiet: opts.quiet,
1930 mode: Some(DupesMode::from(dupes_cfg.mode)),
1931 min_tokens: Some(dupes_cfg.min_tokens),
1932 min_lines: Some(dupes_cfg.min_lines),
1933 min_occurrences: Some(dupes_cfg.min_occurrences),
1934 threshold: Some(dupes_cfg.threshold),
1935 skip_local: dupes_cfg.skip_local,
1936 cross_language: dupes_cfg.cross_language,
1937 ignore_imports: Some(dupes_cfg.ignore_imports),
1938 top: None,
1939 baseline_path: opts.dupes_baseline,
1940 save_baseline_path: None,
1941 production: opts.production_dupes.unwrap_or(opts.production),
1942 production_override: opts.production_dupes,
1943 trace: None,
1944 changed_since,
1945 diff_index: None,
1946 use_shared_diff_index: true,
1947 changed_files,
1948 workspace: opts.workspace,
1949 changed_workspaces: opts.changed_workspaces,
1950 explain: opts.explain,
1951 explain_skipped: opts.explain_skipped,
1952 summary: false,
1953 group_by: opts.group_by,
1954 performance: false,
1955 }
1956}
1957
1958fn run_audit_health<'a>(
1960 opts: &'a AuditOptions<'a>,
1961 changed_since: Option<&'a str>,
1962 shared_parse: Option<fallow_engine::HealthSharedParseData>,
1963) -> Result<Option<HealthResult>, ExitCode> {
1964 let runtime_coverage = match opts.runtime_coverage {
1965 Some(path) => match crate::health::coverage::prepare_options(
1966 path,
1967 opts.min_invocations_hot,
1968 None,
1969 None,
1970 opts.output,
1971 ) {
1972 Ok(options) => Some(options),
1973 Err(code) => return Err(code),
1974 },
1975 None => None,
1976 };
1977
1978 let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
1979 let health_run = if let Some(shared) = shared_parse {
1980 crate::health::execute_health_with_shared_parse(&health_opts, shared)
1981 } else {
1982 crate::health::execute_health(&health_opts)
1983 };
1984 match health_run {
1985 Ok(r) => Ok(Some(r)),
1986 Err(code) => Err(code),
1987 }
1988}
1989
1990fn build_audit_health_options<'a>(
1993 opts: &'a AuditOptions<'a>,
1994 changed_since: Option<&'a str>,
1995 runtime_coverage: Option<fallow_engine::RuntimeCoverageOptions>,
1996) -> HealthOptions<'a> {
1997 HealthOptions {
1998 root: opts.root,
1999 config_path: opts.config_path,
2000 output: opts.output,
2001 no_cache: opts.no_cache,
2002 threads: opts.threads,
2003 quiet: opts.quiet,
2004 thresholds: fallow_engine::HealthThresholdOverrides {
2005 max_cyclomatic: None,
2006 max_cognitive: None,
2007 max_crap: opts.max_crap,
2008 },
2009 top: None,
2010 sort: fallow_engine::HealthSort::Cyclomatic,
2011 production: opts.production_health.unwrap_or(opts.production),
2012 production_override: opts.production_health,
2013 changed_since,
2014 diff_index: None,
2015 use_shared_diff_index: true,
2016 workspace: opts.workspace,
2017 changed_workspaces: opts.changed_workspaces,
2018 baseline: opts.health_baseline,
2019 save_baseline: None,
2020 complexity: true,
2021 file_scores: false,
2022 coverage_gaps: false,
2023 config_activates_coverage_gaps: false,
2024 hotspots: false,
2025 ownership: false,
2026 ownership_emails: None,
2027 targets: false,
2028 css: false,
2029 force_full: false,
2030 score_only_output: false,
2031 enforce_coverage_gap_gate: false,
2032 effort: None,
2033 score: false,
2034 gates: fallow_engine::HealthGateOptions::default(),
2035 since: None,
2036 min_commits: None,
2037 explain: opts.explain,
2038 summary: false,
2039 save_snapshot: None,
2040 trend: false,
2041 coverage_inputs: fallow_engine::HealthCoverageInputs {
2042 coverage: opts.coverage,
2043 coverage_root: opts.coverage_root,
2044 },
2045 performance: opts.performance,
2046 runtime_coverage,
2047 churn_file: None,
2048 complexity_breakdown: false,
2049 group_by: opts.group_by.map(Into::into),
2050 }
2051}
2052
2053#[path = "audit_output.rs"]
2054mod output;
2055
2056pub use output::audit_json_header_input;
2057pub use output::{
2058 insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
2059 print_audit_findings, print_audit_result,
2060};
2061
2062pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
2068 if let Err(e) = fallow_engine::validate_coverage_root_absolute(opts.coverage_root) {
2069 return emit_error(&e, 2, opts.output);
2070 }
2071 let coverage_resolved = opts
2072 .coverage
2073 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2074 let runtime_coverage_resolved = opts
2075 .runtime_coverage
2076 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2077 let resolved_opts = AuditOptions {
2078 coverage: coverage_resolved.as_deref(),
2079 runtime_coverage: runtime_coverage_resolved.as_deref(),
2080 ..*opts
2081 };
2082 match execute_audit(&resolved_opts) {
2083 Ok(result) => {
2084 let mut findings = result
2085 .check
2086 .as_ref()
2087 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
2088 .unwrap_or_default();
2089 if let Some(health) = result.health.as_ref() {
2090 findings.extend(crate::impact::collect_complexity_findings(&health.report));
2091 }
2092 let clones = result
2093 .dupes
2094 .as_ref()
2095 .map(|d| crate::impact::collect_clone_findings(&d.report))
2096 .unwrap_or_default();
2097 let empty_supps: Vec<fallow_types::results::ActiveSuppression> = Vec::new();
2098 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
2099 c.results.active_suppressions.as_slice()
2100 });
2101 let attribution = crate::impact::AttributionInput {
2102 root: opts.root,
2103 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
2104 findings,
2105 clones,
2106 suppressions,
2107 };
2108 crate::impact::record_audit_run(
2109 opts.root,
2110 &result.summary,
2111 &crate::impact::AuditRunRecord {
2112 verdict: result.verdict,
2113 gate: gate_marker.is_some(),
2114 git_sha: result.head_sha.as_deref(),
2115 version: env!("CARGO_PKG_VERSION"),
2116 timestamp: &crate::vital_signs::chrono_timestamp(),
2117 attribution: Some(&attribution),
2118 },
2119 );
2120 if opts.walkthrough_guide {
2123 crate::audit_brief::print_walkthrough_guide_result(&result)
2124 } else if opts.walkthrough {
2125 crate::audit_brief::print_walkthrough_human_result(
2126 &result,
2127 opts.root,
2128 opts.cache_dir,
2129 opts.mark_viewed,
2130 opts.show_cleared,
2131 opts.quiet,
2132 )
2133 } else if let Some(path) = opts.walkthrough_file {
2134 crate::audit_brief::print_walkthrough_file_result(&result, path)
2135 } else if opts.brief {
2136 crate::audit_brief::print_brief_result(
2140 &result,
2141 opts.quiet,
2142 opts.explain,
2143 opts.show_deprioritized,
2144 )
2145 } else {
2146 print_audit_result(&result, opts.quiet, opts.explain)
2147 }
2148 }
2149 Err(code) => code,
2150 }
2151}
2152
2153#[must_use]
2162pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
2163 let brief_opts = AuditOptions {
2165 brief: true,
2166 ..*opts
2167 };
2168 match execute_audit(&brief_opts) {
2169 Ok(result) => crate::audit_brief::print_decision_surface_result(&result, opts.quiet),
2170 Err(code) => code,
2171 }
2172}
2173
2174#[cfg(test)]
2175#[path = "audit_tests.rs"]
2176mod tests;