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::changed_files::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 css: bool,
116 pub css_deep: bool,
119 pub runtime_coverage: Option<&'a std::path::Path>,
125 pub min_invocations_hot: u64,
127 pub brief: bool,
132 pub max_decisions: usize,
136 pub walkthrough_guide: bool,
139 pub walkthrough: bool,
142 pub mark_viewed: &'a [std::path::PathBuf],
145 pub show_cleared: bool,
147 pub walkthrough_file: Option<&'a std::path::Path>,
150 pub show_deprioritized: bool,
155}
156
157#[path = "audit_base_ref.rs"]
158mod base_ref;
159#[path = "audit_cache.rs"]
160mod cache;
161
162#[cfg(test)]
163use base_ref::{auto_detect_base_ref, parse_audit_base_override};
164use base_ref::{get_head_sha, resolve_base_ref};
165#[cfg(test)]
166use cache::{
167 AUDIT_BASE_SNAPSHOT_CACHE_VERSION, CachedAuditKeySnapshot, audit_base_snapshot_cache_dir,
168 audit_base_snapshot_cache_file, cached_from_snapshot, config_file_fingerprint,
169 ensure_audit_base_snapshot_cache_dir, snapshot_from_cached,
170};
171use cache::{
172 AuditBaseSnapshotCacheKey, audit_base_snapshot_cache_key, load_cached_base_snapshot,
173 save_cached_base_snapshot, sorted_keys,
174};
175
176fn styling_finding_gates(rules: &fallow_config::RulesConfig, code: &str) -> bool {
181 let severity = match code {
182 "css-token-drift" => rules.css_token_drift,
183 "css-duplicate-block" => rules.css_duplicate_block,
184 "css-selector-complexity" => rules.css_selector_complexity,
185 "css-dead-surface" => rules.css_dead_surface,
186 "css-broken-reference" => rules.css_broken_reference,
187 _ => fallow_config::Severity::Warn,
188 };
189 severity == fallow_config::Severity::Error
190}
191
192fn compute_verdict(
193 check: Option<&CheckResult>,
194 dupes: Option<&DupesResult>,
195 health: Option<&HealthResult>,
196) -> AuditVerdict {
197 let mut has_errors = false;
198 let mut has_warnings = false;
199
200 if let Some(result) = check {
201 if crate::check::has_error_severity_issues(
202 &result.results,
203 &result.config.rules,
204 Some(&result.config),
205 ) {
206 has_errors = true;
207 } else if result.results.total_issues() > 0 {
208 has_warnings = true;
209 }
210 }
211
212 if let Some(result) = health
213 && !result.report.findings.is_empty()
214 {
215 has_errors = true;
216 }
217
218 if let Some(result) = health
223 && result
224 .report
225 .styling_findings
226 .iter()
227 .any(|finding| styling_finding_gates(&result.config.rules, &finding.code))
228 {
229 has_errors = true;
230 }
231
232 if let Some(result) = dupes
233 && !result.report.clone_groups.is_empty()
234 {
235 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
236 has_errors = true;
237 } else {
238 has_warnings = true;
239 }
240 }
241
242 if has_errors {
243 AuditVerdict::Fail
244 } else if has_warnings {
245 AuditVerdict::Warn
246 } else {
247 AuditVerdict::Pass
248 }
249}
250
251fn build_summary(
252 check: Option<&CheckResult>,
253 dupes: Option<&DupesResult>,
254 health: Option<&HealthResult>,
255) -> AuditSummary {
256 let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
257 let dead_code_has_errors = check.is_some_and(|r| {
258 crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
259 });
260 let complexity_findings = health.map_or(0, |r| r.report.findings.len());
261 let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
262 let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
263
264 AuditSummary {
265 dead_code_issues,
266 dead_code_has_errors,
267 complexity_findings,
268 max_cyclomatic,
269 duplication_clone_groups,
270 }
271}
272
273fn compute_audit_attribution(
274 check: Option<&CheckResult>,
275 dupes: Option<&DupesResult>,
276 health: Option<&HealthResult>,
277 base: Option<&AuditKeySnapshot>,
278 gate: AuditGate,
279) -> AuditAttribution {
280 let dead_code = check
281 .map(|r| {
282 count_introduced(
283 &dead_code_keys(&r.results, &r.config.root),
284 base.map(|b| &b.dead_code),
285 )
286 })
287 .unwrap_or_default();
288 let complexity = health
289 .map(|r| {
290 count_introduced(
291 &health_keys(&r.report, &r.config.root),
292 base.map(|b| &b.health),
293 )
294 })
295 .unwrap_or_default();
296 let duplication = dupes
297 .map(|r| {
298 count_introduced(
299 &dupes_keys(&r.report, &r.config.root),
300 base.map(|b| &b.dupes),
301 )
302 })
303 .unwrap_or_default();
304
305 AuditAttribution {
306 gate,
307 dead_code_introduced: dead_code.0,
308 dead_code_inherited: dead_code.1,
309 complexity_introduced: complexity.0,
310 complexity_inherited: complexity.1,
311 duplication_introduced: duplication.0,
312 duplication_inherited: duplication.1,
313 }
314}
315
316fn compute_introduced_verdict(
317 check: Option<&CheckResult>,
318 dupes: Option<&DupesResult>,
319 health: Option<&HealthResult>,
320 base: Option<&AuditKeySnapshot>,
321) -> AuditVerdict {
322 let mut has_errors = false;
323 let mut has_warnings = false;
324
325 if let Some(result) = check {
326 let (errors, warnings) = introduced_check_verdict(result, base);
327 has_errors |= errors;
328 has_warnings |= warnings;
329 }
330 if let Some(result) = health {
331 has_errors |= introduced_health_has_errors(result, base);
332 }
333 if let Some(result) = health
334 && result
335 .report
336 .styling_findings
337 .iter()
338 .any(|finding| introduced_styling_finding_gates(result, base, finding))
339 {
340 has_errors = true;
341 }
342 if let Some(result) = dupes {
343 let (errors, warnings) = introduced_dupes_verdict(result, base);
344 has_errors |= errors;
345 has_warnings |= warnings;
346 }
347
348 if has_errors {
349 AuditVerdict::Fail
350 } else if has_warnings {
351 AuditVerdict::Warn
352 } else {
353 AuditVerdict::Pass
354 }
355}
356
357fn introduced_check_verdict(result: &CheckResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
359 let base_keys = base.map(|b| &b.dead_code);
360 let mut introduced = result.results.clone();
361 retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
362 if crate::check::has_error_severity_issues(
363 &introduced,
364 &result.config.rules,
365 Some(&result.config),
366 ) {
367 (true, false)
368 } else if introduced.total_issues() > 0 {
369 (false, true)
370 } else {
371 (false, false)
372 }
373}
374
375fn introduced_health_has_errors(result: &HealthResult, base: Option<&AuditKeySnapshot>) -> bool {
377 let base_keys = base.map(|b| &b.health);
378 result.report.findings.iter().any(|finding| {
379 !base_keys
380 .is_some_and(|keys| keys.contains(&health_finding_key(finding, &result.config.root)))
381 })
382}
383
384fn introduced_styling_finding_gates(
385 result: &HealthResult,
386 base: Option<&AuditKeySnapshot>,
387 finding: &fallow_output::StylingFinding,
388) -> bool {
389 styling_finding_gates(&result.config.rules, &finding.code)
390 && !base.is_some_and(|snapshot| {
391 snapshot
392 .styling
393 .contains(&styling_finding_key(finding, &result.config.root))
394 })
395}
396
397fn introduced_dupes_verdict(result: &DupesResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
399 let base_keys = base.map(|b| &b.dupes);
400 let introduced = result
401 .report
402 .clone_groups
403 .iter()
404 .filter(|group| {
405 !base_keys
406 .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
407 })
408 .count();
409 if introduced == 0 {
410 return (false, false);
411 }
412 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
413 (true, false)
414 } else {
415 (false, true)
416 }
417}
418
419pub struct AuditKeySnapshot {
420 dead_code: FxHashSet<String>,
421 health: FxHashSet<String>,
422 styling: FxHashSet<String>,
423 dupes: FxHashSet<String>,
424 boundary_edges: FxHashSet<String>,
428 cycles: FxHashSet<String>,
430 public_api: FxHashSet<String>,
433}
434
435fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
436 let Some(base) = base else {
437 return (0, 0);
438 };
439 keys.iter().fold((0, 0), |(introduced, inherited), key| {
440 if base.contains(key) {
441 (introduced, inherited + 1)
442 } else {
443 (introduced + 1, inherited)
444 }
445 })
446}
447
448fn ambient_git_env_hint() -> Option<String> {
453 use fallow_engine::changed_files::AMBIENT_GIT_ENV_VARS;
454 for var in AMBIENT_GIT_ENV_VARS {
455 if let Ok(value) = std::env::var(var)
456 && !value.is_empty()
457 {
458 return Some(format!(
459 "{var}={value} is set in the environment; if fallow is being \
460invoked from a git hook this can interfere with worktree operations. Re-run \
461with `env -u {var} fallow audit` to confirm."
462 ));
463 }
464 }
465 None
466}
467
468fn compute_base_snapshot(
469 opts: &AuditOptions<'_>,
470 base_ref: &str,
471 changed_files: &FxHashSet<PathBuf>,
472 base_sha: Option<&str>,
473) -> Result<AuditKeySnapshot, ExitCode> {
474 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
475 use std::fmt::Write as _;
476 let mut message =
477 format!("could not create a temporary worktree for base ref '{base_ref}'");
478 if let Some(hint) = ambient_git_env_hint() {
479 let _ = write!(message, "\n hint: {hint}");
480 }
481 return Err(emit_error(&message, 2, opts.output));
482 };
483 let base_root = base_analysis_root(opts.root, worktree.path());
484 let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
485 let current_config_path = opts
486 .config_path
487 .clone()
488 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
489 let base_opts =
490 build_base_audit_options(opts, &base_root, ¤t_config_path, &base_cache_dir);
491
492 let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
493 let check_production = opts.production_dead_code.unwrap_or(opts.production);
494 let health_production = opts.production_health.unwrap_or(opts.production);
495 let share_dead_code_parse_with_health = check_production == health_production;
496
497 let (check_res, dupes_res) = rayon::join(
498 || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
499 || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
500 );
501 let mut check = check_res?;
502 let dupes = dupes_res?;
503 let base_public_api = if opts.brief {
507 public_api_keys_from_check(check.as_ref(), &base_root)
508 } else {
509 FxHashSet::default()
510 };
511 let shared_parse = if share_dead_code_parse_with_health {
512 check.as_mut().and_then(|r| r.shared_parse.take())
513 } else {
514 None
515 };
516 let health = run_audit_health(&base_opts, None, shared_parse)?;
517 if let Some(ref mut check) = check {
518 check.shared_parse = None;
519 }
520
521 Ok(snapshot_from_results(
522 check.as_ref(),
523 dupes.as_ref(),
524 health.as_ref(),
525 base_public_api,
526 ))
527}
528
529fn snapshot_from_results(
535 check: Option<&CheckResult>,
536 dupes: Option<&DupesResult>,
537 health: Option<&HealthResult>,
538 public_api: FxHashSet<String>,
539) -> AuditKeySnapshot {
540 let (boundary_edges, cycles) = check.map_or_else(
541 || (FxHashSet::default(), FxHashSet::default()),
542 |r| {
543 (
544 review_deltas::boundary_edge_keys(&r.results.boundary_violations),
545 review_deltas::cycle_keys(&r.results.circular_dependencies, &r.config.root),
546 )
547 },
548 );
549 AuditKeySnapshot {
550 dead_code: check.map_or_else(FxHashSet::default, |r| {
551 dead_code_keys(&r.results, &r.config.root)
552 }),
553 health: health.map_or_else(FxHashSet::default, |r| {
554 health_keys(&r.report, &r.config.root)
555 }),
556 styling: health.map_or_else(FxHashSet::default, |r| {
557 styling_keys(&r.report, &r.config.root)
558 }),
559 dupes: dupes.map_or_else(FxHashSet::default, |r| {
560 dupes_keys(&r.report, &r.config.root)
561 }),
562 boundary_edges,
563 cycles,
564 public_api,
565 }
566}
567
568fn public_api_keys_from_check(check: Option<&CheckResult>, root: &Path) -> FxHashSet<String> {
573 let Some(check) = check else {
574 return FxHashSet::default();
575 };
576 let Some(graph) = check
577 .shared_parse
578 .as_ref()
579 .and_then(|sp| sp.analysis_output.as_ref())
580 .and_then(|out| out.graph.as_ref())
581 else {
582 return FxHashSet::default();
583 };
584 review_deltas::public_export_keys_for(graph, &check.config, &check.workspaces, root)
585}
586
587#[expect(
589 clippy::ref_option,
590 reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
591)]
592fn build_base_audit_options<'a>(
593 opts: &AuditOptions<'a>,
594 base_root: &'a Path,
595 current_config_path: &'a Option<PathBuf>,
596 base_cache_dir: &'a Path,
597) -> AuditOptions<'a> {
598 AuditOptions {
599 root: base_root,
600 config_path: current_config_path,
601 cache_dir: base_cache_dir,
602 output: opts.output,
603 no_cache: opts.no_cache,
604 threads: opts.threads,
605 quiet: true,
606 changed_since: None,
607 production: opts.production,
608 production_dead_code: opts.production_dead_code,
609 production_health: opts.production_health,
610 production_dupes: opts.production_dupes,
611 workspace: opts.workspace,
612 changed_workspaces: None,
613 explain: false,
614 explain_skipped: false,
615 performance: false,
616 group_by: opts.group_by,
617 dead_code_baseline: None,
618 health_baseline: None,
619 dupes_baseline: None,
620 max_crap: opts.max_crap,
621 coverage: opts.coverage,
622 coverage_root: opts.coverage_root,
623 gate: AuditGate::All,
624 include_entry_exports: opts.include_entry_exports,
625 css: opts.css,
628 css_deep: opts.css_deep,
629 runtime_coverage: None,
630 min_invocations_hot: opts.min_invocations_hot,
631 brief: false,
632 max_decisions: 4,
633 walkthrough_guide: false,
634 walkthrough: false,
635 mark_viewed: &[],
636 show_cleared: false,
637 walkthrough_file: None,
638 show_deprioritized: false,
639 }
640}
641
642fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
643 let Some(git_root) = git_toplevel(current_root) else {
644 return base_worktree_root.to_path_buf();
645 };
646 let current_root =
647 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
648 match current_root.strip_prefix(&git_root) {
649 Ok(relative) => base_worktree_root.join(relative),
650 Err(err) => {
651 tracing::warn!(
652 current_root = %current_root.display(),
653 git_root = %git_root.display(),
654 error = %err,
655 "Could not remap audit base root into the base worktree; falling back to worktree root"
656 );
657 base_worktree_root.to_path_buf()
658 }
659 }
660}
661
662fn current_keys_as_base_keys(
663 check: Option<&CheckResult>,
664 dupes: Option<&DupesResult>,
665 health: Option<&HealthResult>,
666) -> AuditKeySnapshot {
667 let public_api = check
672 .and_then(|r| r.public_api_keys.clone())
673 .unwrap_or_default();
674 snapshot_from_results(check, dupes, health, public_api)
675}
676
677fn can_reuse_current_as_base(
678 opts: &AuditOptions<'_>,
679 base_ref: &str,
680 changed_files: &FxHashSet<PathBuf>,
681) -> bool {
682 let Some(git_root) = git_toplevel(opts.root) else {
683 return false;
684 };
685 let cache_dir = opts.cache_dir.to_path_buf();
686 let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
687 let mut reader: Option<BaseFileReader> = None;
690 for path in changed_files {
691 if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
692 continue;
693 }
694 if !is_analysis_input(path) {
695 if is_non_behavioral_doc(path) {
696 continue;
697 }
698 return false;
699 }
700 let Ok(current) = std::fs::read_to_string(path) else {
701 return false;
702 };
703 let Ok(relative) = path.strip_prefix(&git_root) else {
704 return false;
705 };
706 let reader = match reader.as_mut() {
707 Some(reader) => reader,
708 None => {
709 let Some(spawned) = BaseFileReader::spawn(opts.root) else {
710 return false;
711 };
712 reader.insert(spawned)
713 }
714 };
715 let Some(base) = reader.read(base_ref, relative) else {
716 return false;
717 };
718 if current == base {
719 continue;
720 }
721 if !js_ts_tokens_equivalent(path, ¤t, &base) {
722 return false;
723 }
724 }
725 true
726}
727
728struct BaseFileReader {
741 child: Option<crate::signal::ScopedChild>,
745 stdin: Option<std::process::ChildStdin>,
748 stdout: std::io::BufReader<std::process::ChildStdout>,
749}
750
751impl BaseFileReader {
752 fn spawn(root: &Path) -> Option<Self> {
758 let mut command = Command::new("git");
759 command
760 .args(["cat-file", "--batch"])
761 .current_dir(root)
762 .stdin(std::process::Stdio::piped())
763 .stdout(std::process::Stdio::piped())
764 .stderr(std::process::Stdio::null());
765 clear_ambient_git_env(&mut command);
766 let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
767 let stdin = child.take_stdin()?;
768 let stdout = child.take_stdout()?;
769 Some(Self {
770 child: Some(child),
771 stdin: Some(stdin),
772 stdout: std::io::BufReader::new(stdout),
773 })
774 }
775
776 fn read(&mut self, base_ref: &str, relative: &Path) -> Option<String> {
783 use std::io::{BufRead, Read};
784
785 let relative = relative.to_string_lossy().replace('\\', "/");
786 if relative.contains('\n') {
789 return None;
790 }
791
792 let stdin = self.stdin.as_mut()?;
793 writeln!(stdin, "{base_ref}:{relative}").ok()?;
794 stdin.flush().ok()?;
795
796 let mut header = String::new();
797 if self.stdout.read_line(&mut header).ok()? == 0 {
798 return None;
799 }
800 if header.trim_end().ends_with(" missing") {
802 return None;
803 }
804 let size: usize = header.trim_end().rsplit(' ').next()?.parse().ok()?;
806 let mut buf = vec![0u8; size];
807 self.stdout.read_exact(&mut buf).ok()?;
808 let mut newline = [0u8; 1];
811 self.stdout.read_exact(&mut newline).ok()?;
812
813 Some(String::from_utf8_lossy(&buf).into_owned())
814 }
815}
816
817impl Drop for BaseFileReader {
818 fn drop(&mut self) {
819 self.stdin.take();
824 if let Some(child) = self.child.take() {
825 let _ = child.wait();
826 }
827 }
828}
829
830fn is_fallow_cache_artifact(
831 path: &Path,
832 cache_dir: &Path,
833 canonical_cache_dir: Option<&Path>,
834) -> bool {
835 path.starts_with(cache_dir)
836 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
837}
838
839fn remap_cache_dir_for_base_worktree(
840 current_root: &Path,
841 base_worktree_root: &Path,
842 cache_dir: &Path,
843) -> PathBuf {
844 if cache_dir.is_absolute()
845 && let Ok(relative) = cache_dir.strip_prefix(current_root)
846 {
847 return base_worktree_root.join(relative);
848 }
849 cache_dir.to_path_buf()
850}
851
852fn is_analysis_input(path: &Path) -> bool {
853 matches!(
854 path.extension().and_then(|ext| ext.to_str()),
855 Some(
856 "js" | "jsx"
857 | "ts"
858 | "tsx"
859 | "mjs"
860 | "mts"
861 | "cjs"
862 | "cts"
863 | "vue"
864 | "svelte"
865 | "astro"
866 | "mdx"
867 | "css"
868 | "scss"
869 )
870 )
871}
872
873fn is_non_behavioral_doc(path: &Path) -> bool {
874 matches!(
875 path.extension().and_then(|ext| ext.to_str()),
876 Some("md" | "markdown" | "txt" | "rst" | "adoc")
877 )
878}
879
880fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
881 if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
882 return false;
883 }
884 if !matches!(
885 path.extension().and_then(|ext| ext.to_str()),
886 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
887 ) {
888 return false;
889 }
890 fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
891}
892
893fn remap_focus_files(
894 files: &FxHashSet<PathBuf>,
895 from_root: &Path,
896 to_root: &Path,
897) -> Option<FxHashSet<PathBuf>> {
898 let mut remapped = FxHashSet::default();
899 for file in files {
900 if let Ok(relative) = file.strip_prefix(from_root) {
901 remapped.insert(to_root.join(relative));
902 }
903 }
904 if remapped.is_empty() {
905 return None;
906 }
907 Some(remapped)
908}
909
910#[cfg(test)]
911use std::time::SystemTime;
912
913#[cfg(test)]
914use crate::base_worktree::{
915 ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
916 is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
917 materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
918 remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
919 sweep_orphan_audit_worktrees, touch_last_used,
920};
921
922pub use fallow_api::audit_keys as keys;
923
924#[path = "audit_review_deltas.rs"]
925pub mod review_deltas;
926
927#[path = "audit_weakening.rs"]
928pub mod weakening;
929
930#[path = "audit_routing.rs"]
931pub mod routing;
932
933use keys::{
934 dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
935 retain_introduced_dead_code, styling_finding_key, styling_keys,
936};
937
938struct HeadAnalyses {
939 check: Option<CheckResult>,
940 dupes: Option<DupesResult>,
941 health: Option<HealthResult>,
942}
943
944type HeadAndBaseResult = (
947 Result<HeadAnalyses, ExitCode>,
948 Option<Result<AuditKeySnapshot, ExitCode>>,
949);
950
951fn run_audit_head_and_base(
954 opts: &AuditOptions<'_>,
955 changed_since: Option<&str>,
956 changed_files: &FxHashSet<PathBuf>,
957 base_ref: &str,
958 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
959 run_base: bool,
960) -> HeadAndBaseResult {
961 if run_base {
962 let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
963 let (h, b) = rayon::join(
964 || run_audit_head_analyses(opts, changed_since, changed_files),
965 || compute_base_snapshot(opts, base_ref, changed_files, base_sha),
966 );
967 (h, Some(b))
968 } else {
969 (
970 run_audit_head_analyses(opts, changed_since, changed_files),
971 None,
972 )
973 }
974}
975
976struct AuditResultParts {
977 verdict: AuditVerdict,
978 summary: AuditSummary,
979 attribution: AuditAttribution,
980 base_snapshot: Option<AuditKeySnapshot>,
981 base_snapshot_skipped: bool,
982 changed_files_count: usize,
983 changed_files: FxHashSet<PathBuf>,
984 base_ref: String,
985 base_description: Option<String>,
986 head_sha: Option<String>,
987 output: OutputFormat,
988 performance: bool,
989 check: Option<CheckResult>,
990 dupes: Option<DupesResult>,
991 health: Option<HealthResult>,
992 elapsed: Duration,
993 review_deltas: Option<crate::audit_brief::ReviewDeltas>,
994 weakening_signals: Vec<weakening::WeakeningSignal>,
995 routing: Option<routing::RoutingFacts>,
996 decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
997 graph_snapshot_hash: Option<String>,
998 change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
999}
1000
1001#[derive(Default)]
1002struct AuditBriefData {
1003 review_deltas: Option<crate::audit_brief::ReviewDeltas>,
1004 weakening_signals: Vec<weakening::WeakeningSignal>,
1005 routing: Option<routing::RoutingFacts>,
1006 decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
1007 graph_snapshot_hash: Option<String>,
1008 change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
1009}
1010
1011#[derive(Clone, Copy)]
1012struct AuditBriefDataInput<'a> {
1013 opts: &'a AuditOptions<'a>,
1014 check: Option<&'a CheckResult>,
1015 dupes: Option<&'a DupesResult>,
1016 health: Option<&'a HealthResult>,
1017 base_snapshot: Option<&'a AuditKeySnapshot>,
1018 changed_files: &'a FxHashSet<PathBuf>,
1019 base_ref: &'a str,
1020 head_sha: Option<&'a str>,
1021}
1022
1023fn run_audit_head_analyses(
1030 opts: &AuditOptions<'_>,
1031 changed_since: Option<&str>,
1032 changed_files: &FxHashSet<PathBuf>,
1033) -> Result<HeadAnalyses, ExitCode> {
1034 let check_production = opts.production_dead_code.unwrap_or(opts.production);
1035 let health_production = opts.production_health.unwrap_or(opts.production);
1036 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
1037 let share_dead_code_parse_with_health = check_production == health_production;
1038 let share_dead_code_files_with_dupes =
1039 share_dead_code_parse_with_health && check_production == dupes_production;
1040
1041 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
1042 let dupes_files = if share_dead_code_files_with_dupes {
1043 check
1044 .as_ref()
1045 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
1046 } else {
1047 None
1048 };
1049 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
1050 if opts.brief
1055 && let Some(ref mut check) = check
1056 {
1057 check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
1058 check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
1059 check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
1060 check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
1061 check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
1062 check.internal_consumers =
1063 compute_brief_internal_consumers(opts.root, check, changed_files);
1064 }
1065 let shared_parse = if share_dead_code_parse_with_health {
1066 check.as_mut().and_then(|r| r.shared_parse.take())
1067 } else {
1068 None
1069 };
1070 let health = run_audit_health(opts, changed_since, shared_parse)?;
1071 Ok(HeadAnalyses {
1072 check,
1073 dupes,
1074 health,
1075 })
1076}
1077
1078fn compute_brief_impact_closure(
1086 root: &std::path::Path,
1087 check: &CheckResult,
1088 changed_files: &FxHashSet<PathBuf>,
1089) -> Option<fallow_engine::module_graph::ImpactClosurePaths> {
1090 let graph = check
1091 .shared_parse
1092 .as_ref()
1093 .and_then(|sp| sp.analysis_output.as_ref())
1094 .and_then(|out| out.graph.as_ref())?;
1095
1096 fallow_engine::module_graph::impact_closure_for_changed_paths(graph, root, changed_files)
1097}
1098
1099fn compute_brief_partition_order(
1107 root: &std::path::Path,
1108 check: &CheckResult,
1109 changed_files: &FxHashSet<PathBuf>,
1110) -> Option<fallow_engine::module_graph::PartitionOrderPaths> {
1111 let graph = check
1112 .shared_parse
1113 .as_ref()
1114 .and_then(|sp| sp.analysis_output.as_ref())
1115 .and_then(|out| out.graph.as_ref())?;
1116
1117 fallow_engine::module_graph::partition_order_for_changed_paths(graph, root, changed_files)
1118}
1119
1120fn compute_brief_export_lines(
1125 root: &std::path::Path,
1126 check: &CheckResult,
1127 changed_files: &FxHashSet<PathBuf>,
1128) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
1129 let graph = check
1130 .shared_parse
1131 .as_ref()
1132 .and_then(|sp| sp.analysis_output.as_ref())
1133 .and_then(|out| out.graph.as_ref())?;
1134
1135 fallow_engine::module_graph::export_lines_for_changed_paths(graph, root, changed_files)
1136}
1137
1138fn compute_brief_internal_consumers(
1147 root: &std::path::Path,
1148 check: &CheckResult,
1149 changed_files: &FxHashSet<PathBuf>,
1150) -> Option<FxHashMap<String, u64>> {
1151 let graph = check
1152 .shared_parse
1153 .as_ref()
1154 .and_then(|sp| sp.analysis_output.as_ref())
1155 .and_then(|out| out.graph.as_ref())?;
1156
1157 fallow_engine::module_graph::internal_consumers_for_changed_paths(graph, root, changed_files)
1158}
1159
1160fn compute_brief_focus_facts(
1170 root: &std::path::Path,
1171 check: &CheckResult,
1172 changed_files: &FxHashSet<PathBuf>,
1173) -> Option<Vec<fallow_engine::module_graph::FocusFileFactsPaths>> {
1174 let graph = check
1175 .shared_parse
1176 .as_ref()
1177 .and_then(|sp| sp.analysis_output.as_ref())
1178 .and_then(|out| out.graph.as_ref())?;
1179
1180 fallow_engine::module_graph::focus_facts_for_changed_paths(graph, root, changed_files)
1181}
1182
1183pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1185 let start = Instant::now();
1186
1187 let (base_ref, base_description) = resolve_base_ref(opts)?;
1188
1189 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1190 return Err(emit_error(
1191 &format!(
1192 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1193 ),
1194 2,
1195 opts.output,
1196 ));
1197 };
1198 let changed_files_count = changed_files.len();
1199
1200 if changed_files.is_empty() {
1201 return Ok(empty_audit_result(
1202 base_ref,
1203 base_description,
1204 opts,
1205 start.elapsed(),
1206 ));
1207 }
1208
1209 sweep_old_reusable_caches(
1213 opts.root,
1214 resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1215 opts.quiet,
1216 );
1217
1218 let changed_since = Some(base_ref.as_str());
1219
1220 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1221 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1222 let base_cache_key = if needs_real_base_snapshot {
1223 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1224 } else {
1225 None
1226 };
1227 let cached_base_snapshot = base_cache_key
1228 .as_ref()
1229 .and_then(|key| load_cached_base_snapshot(opts, key));
1230
1231 let (head_res, base_res) = run_audit_head_and_base(
1232 opts,
1233 changed_since,
1234 &changed_files,
1235 &base_ref,
1236 base_cache_key.as_ref(),
1237 needs_real_base_snapshot && cached_base_snapshot.is_none(),
1238 );
1239
1240 assemble_audit_result(AuditAssemblyInput {
1241 opts,
1242 head_res,
1243 base_res,
1244 cached_base_snapshot,
1245 base_cache_key,
1246 changed_files,
1247 changed_files_count,
1248 base_ref,
1249 base_description,
1250 start,
1251 })
1252}
1253
1254struct AuditAssemblyInput<'a> {
1256 opts: &'a AuditOptions<'a>,
1257 head_res: Result<HeadAnalyses, ExitCode>,
1258 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1259 cached_base_snapshot: Option<AuditKeySnapshot>,
1260 base_cache_key: Option<AuditBaseSnapshotCacheKey>,
1261 changed_files: FxHashSet<PathBuf>,
1262 changed_files_count: usize,
1263 base_ref: String,
1264 base_description: Option<String>,
1265 start: Instant,
1266}
1267
1268fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
1271 let opts = input.opts;
1272 let head = input.head_res?;
1273 let mut check_result = head.check;
1274 let dupes_result = head.dupes;
1275 let health_result = head.health;
1276
1277 let (base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
1278 opts,
1279 input.cached_base_snapshot,
1280 input.base_res,
1281 input.base_cache_key.as_ref(),
1282 CurrentAnalysisRefs {
1283 check: check_result.as_ref(),
1284 dupes: dupes_result.as_ref(),
1285 health: health_result.as_ref(),
1286 },
1287 )?;
1288 drop_check_shared_parse(&mut check_result);
1289 let (attribution, verdict, summary) = compute_audit_outcome(
1290 opts.gate,
1291 check_result.as_ref(),
1292 dupes_result.as_ref(),
1293 health_result.as_ref(),
1294 base_snapshot.as_ref(),
1295 );
1296
1297 let head_sha = get_head_sha(opts.root);
1298 let brief = compute_audit_brief_data(AuditBriefDataInput {
1299 opts,
1300 check: check_result.as_ref(),
1301 dupes: dupes_result.as_ref(),
1302 health: health_result.as_ref(),
1303 base_snapshot: base_snapshot.as_ref(),
1304 changed_files: &input.changed_files,
1305 base_ref: &input.base_ref,
1306 head_sha: head_sha.as_deref(),
1307 });
1308
1309 Ok(build_audit_result(AuditResultParts {
1310 verdict,
1311 summary,
1312 attribution,
1313 base_snapshot,
1314 base_snapshot_skipped,
1315 changed_files_count: input.changed_files_count,
1316 changed_files: input.changed_files,
1317 base_ref: input.base_ref,
1318 base_description: input.base_description,
1319 head_sha,
1320 output: opts.output,
1321 performance: opts.performance,
1322 check: check_result,
1323 dupes: dupes_result,
1324 health: health_result,
1325 elapsed: input.start.elapsed(),
1326 review_deltas: brief.review_deltas,
1327 weakening_signals: brief.weakening_signals,
1328 routing: brief.routing,
1329 decision_surface: brief.decision_surface,
1330 graph_snapshot_hash: brief.graph_snapshot_hash,
1331 change_anchors: brief.change_anchors,
1332 }))
1333}
1334
1335fn drop_check_shared_parse(check_result: &mut Option<CheckResult>) {
1336 if let Some(check) = check_result {
1337 check.shared_parse = None;
1338 }
1339}
1340
1341fn compute_audit_brief_data(input: AuditBriefDataInput<'_>) -> AuditBriefData {
1342 if !input.opts.brief {
1343 return AuditBriefData::default();
1344 }
1345
1346 let (review_deltas, weakening_signals, routing) = compute_brief_e3_data(
1348 input.opts,
1349 input.check,
1350 input.base_snapshot,
1351 input.changed_files,
1352 input.base_ref,
1353 );
1354
1355 let decision_surface = Some(compute_decision_surface(
1357 input.opts,
1358 input.check,
1359 review_deltas.as_ref(),
1360 routing.as_ref(),
1361 ));
1362
1363 let change_anchors = compute_change_anchors(input.opts.root, input.base_ref);
1365
1366 let graph_snapshot_hash = Some(compute_graph_snapshot_hash(
1368 input.check,
1369 input.dupes,
1370 input.health,
1371 input.base_ref,
1372 input.head_sha,
1373 &change_anchors,
1374 ));
1375
1376 AuditBriefData {
1377 review_deltas,
1378 weakening_signals,
1379 routing,
1380 decision_surface,
1381 graph_snapshot_hash,
1382 change_anchors,
1383 }
1384}
1385
1386fn compute_graph_snapshot_hash(
1396 check: Option<&CheckResult>,
1397 dupes: Option<&DupesResult>,
1398 health: Option<&HealthResult>,
1399 base_ref: &str,
1400 head_sha: Option<&str>,
1401 change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
1402) -> String {
1403 let public_api = check
1407 .and_then(|c| c.public_api_keys.clone())
1408 .unwrap_or_default();
1409 let snapshot = snapshot_from_results(check, dupes, health, public_api);
1410 let mut bytes: Vec<u8> = Vec::new();
1411 for set in [
1413 &snapshot.dead_code,
1414 &snapshot.health,
1415 &snapshot.dupes,
1416 &snapshot.boundary_edges,
1417 &snapshot.cycles,
1418 &snapshot.public_api,
1419 ] {
1420 for key in sorted_keys(set) {
1421 bytes.extend_from_slice(key.as_bytes());
1422 bytes.push(0);
1423 }
1424 bytes.push(1);
1425 }
1426 let mut anchor_ids: Vec<&str> = change_anchors
1431 .iter()
1432 .map(|a| a.change_anchor.as_str())
1433 .collect();
1434 anchor_ids.sort_unstable();
1435 for id in anchor_ids {
1436 bytes.extend_from_slice(id.as_bytes());
1437 bytes.push(0);
1438 }
1439 bytes.push(1);
1440 bytes.extend_from_slice(base_ref.as_bytes());
1441 bytes.push(0);
1442 bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
1443 format!("graph:{:016x}", xxh3_64(&bytes))
1444}
1445
1446fn compute_change_anchors(
1454 root: &std::path::Path,
1455 base_ref: &str,
1456) -> Vec<crate::audit_walkthrough::ChangeAnchor> {
1457 if let Some(raw) = crate::report::ci::diff_filter::shared_diff_raw() {
1458 return crate::audit_walkthrough::parse_change_anchors(raw);
1459 }
1460 fallow_engine::changed_files::try_get_changed_diff(root, base_ref)
1461 .map(|diff| crate::audit_walkthrough::parse_change_anchors(&diff))
1462 .unwrap_or_default()
1463}
1464
1465fn compute_decision_surface(
1471 opts: &AuditOptions<'_>,
1472 check: Option<&CheckResult>,
1473 review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
1474 routing: Option<&routing::RoutingFacts>,
1475) -> crate::audit_decision_surface::DecisionSurface {
1476 use crate::audit_decision_surface::{
1477 CoordinationAnchor, DecisionInputs, extract_decision_surface,
1478 };
1479
1480 let (Some(check), Some(deltas)) = (check, review_deltas) else {
1481 return crate::audit_decision_surface::DecisionSurface::default();
1482 };
1483 let root = &check.config.root;
1484
1485 let boundary_anchors = decision_boundary_anchors(check, deltas, root);
1486
1487 let closure = check.impact_closure.as_ref();
1491 let mut coordination: Vec<CoordinationAnchor> = closure
1492 .map(|c| aggregate_coordination_gaps(&c.coordination_gap))
1493 .unwrap_or_default();
1494 let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
1495
1496 let empty_routing = routing::RoutingFacts::default();
1497 let routing = routing.unwrap_or(&empty_routing);
1498
1499 let root_owned = root.clone();
1503 let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
1504
1505 for anchor in &mut coordination {
1510 anchor.line = resolve_export_line(
1511 check.export_lines.as_ref(),
1512 &anchor.changed_file,
1513 &anchor.consumed_symbols,
1514 );
1515 }
1516 let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
1517 let mut parts = key.splitn(2, "::");
1518 let path = parts.next().unwrap_or_default();
1519 let name = parts.next().unwrap_or_default();
1520 resolve_export_line(check.export_lines.as_ref(), path, &[name.to_string()])
1521 });
1522
1523 let rename_old_path = |rel: &str| -> Option<String> {
1527 crate::report::ci::diff_filter::shared_diff_index()
1528 .and_then(|idx| idx.old_path_for(rel))
1529 .map(str::to_string)
1530 };
1531
1532 let internal_consumers_map = check.internal_consumers.as_ref();
1535 let internal_consumers = |rel: &str| -> u64 {
1536 internal_consumers_map
1537 .and_then(|map| map.get(rel))
1538 .copied()
1539 .unwrap_or(0)
1540 };
1541
1542 extract_decision_surface(&DecisionInputs {
1543 deltas,
1544 boundary_anchors: &boundary_anchors,
1545 coordination: &coordination,
1546 public_api_anchor_line,
1547 affected_not_shown,
1548 routing,
1549 head_source: &head_source,
1550 rename_old_path: &rename_old_path,
1551 internal_consumers: &internal_consumers,
1552 cap: opts.max_decisions,
1553 })
1554}
1555
1556fn decision_boundary_anchors(
1557 check: &CheckResult,
1558 deltas: &crate::audit_brief::ReviewDeltas,
1559 root: &std::path::Path,
1560) -> Vec<crate::audit_decision_surface::BoundaryAnchor> {
1561 use crate::audit_decision_surface::BoundaryAnchor;
1562
1563 let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
1564 let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
1565 for finding in &check.results.boundary_violations {
1566 let key = review_deltas::boundary_edge_key(finding);
1567 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
1568 continue;
1569 }
1570 boundary_anchors.push(BoundaryAnchor {
1571 zone_pair_key: key,
1572 from_file: keys::relative_key_path(&finding.violation.from_path, root),
1573 from_zone: finding.violation.from_zone.clone(),
1574 to_zone: finding.violation.to_zone.clone(),
1575 line: finding.violation.line,
1576 });
1577 }
1578 boundary_anchors
1579}
1580
1581fn resolve_export_line(
1582 export_lines: Option<&FxHashMap<String, Vec<(String, u32)>>>,
1583 rel: &str,
1584 symbols: &[String],
1585) -> u32 {
1586 let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
1587 return 0;
1588 };
1589 exports
1590 .iter()
1591 .find(|(name, _)| symbols.iter().any(|s| name == s))
1592 .or_else(|| exports.first())
1593 .map_or(0, |(_, line)| *line)
1594}
1595
1596fn aggregate_coordination_gaps(
1601 gaps: &[fallow_engine::module_graph::CoordinationGapPaths],
1602) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
1603 use crate::audit_decision_surface::CoordinationAnchor;
1604 let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
1605 for gap in gaps {
1606 let entry = by_file
1607 .entry(gap.changed_file.clone())
1608 .or_insert_with(|| (0, FxHashSet::default()));
1609 entry.0 += 1;
1610 for symbol in &gap.consumed_symbols {
1611 entry.1.insert(symbol.clone());
1612 }
1613 }
1614 let mut anchors: Vec<CoordinationAnchor> = by_file
1615 .into_iter()
1616 .map(|(changed_file, (consumer_count, symbols))| {
1617 let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
1618 consumed_symbols.sort_unstable();
1619 CoordinationAnchor {
1620 changed_file,
1621 consumed_symbols,
1622 consumer_count,
1623 line: 0,
1624 }
1625 })
1626 .collect();
1627 anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
1628 anchors
1629}
1630
1631fn compute_brief_e3_data(
1636 opts: &AuditOptions<'_>,
1637 check: Option<&CheckResult>,
1638 base_snapshot: Option<&AuditKeySnapshot>,
1639 changed_files: &FxHashSet<PathBuf>,
1640 base_ref: &str,
1641) -> (
1642 Option<crate::audit_brief::ReviewDeltas>,
1643 Vec<weakening::WeakeningSignal>,
1644 Option<routing::RoutingFacts>,
1645) {
1646 let deltas = check.map(|check| {
1647 let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
1648 let head_cycles =
1649 review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
1650 let head_public_api = check.public_api_keys.clone().unwrap_or_default();
1651 let empty = FxHashSet::default();
1652 let (base_boundary, base_cycles, base_public_api) = base_snapshot
1653 .map_or((&empty, &empty, &empty), |b| {
1654 (&b.boundary_edges, &b.cycles, &b.public_api)
1655 });
1656 crate::audit_brief::build_review_deltas(
1657 &head_boundary,
1658 base_boundary,
1659 &head_cycles,
1660 base_cycles,
1661 &head_public_api,
1662 base_public_api,
1663 )
1664 });
1665
1666 let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
1667
1668 let routing =
1669 check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
1670
1671 (deltas, weakening_signals, routing)
1672}
1673
1674fn compute_weakening_signals(
1679 root: &Path,
1680 base_ref: &str,
1681 changed_files: &FxHashSet<PathBuf>,
1682) -> Vec<weakening::WeakeningSignal> {
1683 let Some(git_root) = git_toplevel(root) else {
1684 return Vec::new();
1685 };
1686 let Some(mut reader) = BaseFileReader::spawn(root) else {
1687 return Vec::new();
1688 };
1689
1690 let mut signals = Vec::new();
1691 let mut files: Vec<&PathBuf> = changed_files.iter().collect();
1693 files.sort();
1694
1695 for abs in files {
1696 let Ok(relative) = abs.strip_prefix(&git_root) else {
1697 continue;
1698 };
1699 let rel_str = relative.to_string_lossy().replace('\\', "/");
1700 let head = std::fs::read_to_string(abs).unwrap_or_default();
1701 let base = reader.read(base_ref, relative).unwrap_or_default();
1702 signals.extend(weakening_signals_for_file(&rel_str, &base, &head));
1706 }
1707 signals
1708}
1709
1710fn weakening_signals_for_file(
1711 rel_str: &str,
1712 base: &str,
1713 head: &str,
1714) -> Vec<weakening::WeakeningSignal> {
1715 use weakening::WeakeningKind;
1716
1717 let mut signals = Vec::new();
1718 if weakening::is_test_file(rel_str) {
1719 extend_weakening_signals(
1720 &mut signals,
1721 WeakeningKind::TestWeakened,
1722 rel_str,
1723 weakening::detect_test_weakening(base, head)
1724 .into_iter()
1725 .map(|token| format!("{token} added")),
1726 );
1727 extend_weakening_signals(
1728 &mut signals,
1729 WeakeningKind::TestWeakened,
1730 rel_str,
1731 weakening::detect_removed_tests(base, head),
1732 );
1733 }
1734 extend_weakening_signals(
1735 &mut signals,
1736 WeakeningKind::SuppressionAdded,
1737 rel_str,
1738 weakening::detect_added_suppressions(base, head),
1739 );
1740 extend_weakening_signals(
1741 &mut signals,
1742 WeakeningKind::ThresholdLowered,
1743 rel_str,
1744 weakening::detect_lowered_thresholds(base, head),
1745 );
1746 if weakening::is_ci_file(rel_str) {
1747 extend_weakening_signals(
1748 &mut signals,
1749 WeakeningKind::SecurityCheckRemoved,
1750 rel_str,
1751 weakening::detect_removed_security_steps(base, head),
1752 );
1753 }
1754 signals
1755}
1756
1757fn extend_weakening_signals(
1758 signals: &mut Vec<weakening::WeakeningSignal>,
1759 kind: weakening::WeakeningKind,
1760 file: &str,
1761 evidences: impl IntoIterator<Item = String>,
1762) {
1763 signals.extend(
1764 evidences
1765 .into_iter()
1766 .map(|evidence| weakening::WeakeningSignal {
1767 kind,
1768 file: file.to_owned(),
1769 evidence,
1770 }),
1771 );
1772}
1773
1774fn compute_audit_outcome(
1777 gate: AuditGate,
1778 check: Option<&CheckResult>,
1779 dupes: Option<&DupesResult>,
1780 health: Option<&HealthResult>,
1781 base: Option<&AuditKeySnapshot>,
1782) -> (AuditAttribution, AuditVerdict, AuditSummary) {
1783 let attribution = compute_audit_attribution(check, dupes, health, base, gate);
1784 let verdict = compute_audit_verdict(gate, check, dupes, health, base);
1785 let summary = build_summary(check, dupes, health);
1786 crate::telemetry::note_final_result_count(
1787 summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1788 );
1789 (attribution, verdict, summary)
1790}
1791
1792#[derive(Clone, Copy)]
1799struct CurrentAnalysisRefs<'a> {
1800 check: Option<&'a CheckResult>,
1801 dupes: Option<&'a DupesResult>,
1802 health: Option<&'a HealthResult>,
1803}
1804
1805fn resolve_base_snapshot(
1806 opts: &AuditOptions<'_>,
1807 cached_base_snapshot: Option<AuditKeySnapshot>,
1808 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1809 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
1810 current: CurrentAnalysisRefs<'_>,
1811) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
1812 if !matches!(opts.gate, AuditGate::NewOnly) {
1813 return Ok((None, false));
1814 }
1815 if let Some(snapshot) = cached_base_snapshot {
1816 return Ok((Some(snapshot), false));
1817 }
1818 if let Some(base_res) = base_res {
1819 let snapshot = base_res?;
1820 if let Some(key) = base_cache_key {
1821 save_cached_base_snapshot(opts, key, &snapshot);
1822 }
1823 return Ok((Some(snapshot), false));
1824 }
1825 let CurrentAnalysisRefs {
1826 check,
1827 dupes,
1828 health,
1829 } = current;
1830 Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
1831}
1832
1833fn compute_audit_verdict(
1836 gate: AuditGate,
1837 check: Option<&CheckResult>,
1838 dupes: Option<&DupesResult>,
1839 health: Option<&HealthResult>,
1840 base: Option<&AuditKeySnapshot>,
1841) -> AuditVerdict {
1842 if matches!(gate, AuditGate::NewOnly) {
1843 compute_introduced_verdict(check, dupes, health, base)
1844 } else {
1845 compute_verdict(check, dupes, health)
1846 }
1847}
1848
1849fn build_audit_result(parts: AuditResultParts) -> AuditResult {
1850 AuditResult {
1851 verdict: parts.verdict,
1852 summary: parts.summary,
1853 attribution: parts.attribution,
1854 base_snapshot: parts.base_snapshot,
1855 base_snapshot_skipped: parts.base_snapshot_skipped,
1856 changed_files_count: parts.changed_files_count,
1857 changed_files: parts.changed_files.into_iter().collect(),
1858 base_ref: parts.base_ref,
1859 base_description: parts.base_description,
1860 head_sha: parts.head_sha,
1861 output: parts.output,
1862 performance: parts.performance,
1863 check: parts.check,
1864 dupes: parts.dupes,
1865 health: parts.health,
1866 elapsed: parts.elapsed,
1867 review_deltas: parts.review_deltas,
1868 weakening_signals: parts.weakening_signals,
1869 routing: parts.routing,
1870 decision_surface: parts.decision_surface,
1871 graph_snapshot_hash: parts.graph_snapshot_hash,
1872 change_anchors: parts.change_anchors,
1873 }
1874}
1875
1876fn empty_audit_result(
1878 base_ref: String,
1879 base_description: Option<String>,
1880 opts: &AuditOptions<'_>,
1881 elapsed: Duration,
1882) -> AuditResult {
1883 crate::telemetry::note_final_result_count(0);
1884
1885 let head_sha = get_head_sha(opts.root);
1886 let graph_snapshot_hash = if opts.brief {
1890 Some(compute_graph_snapshot_hash(
1892 None,
1893 None,
1894 None,
1895 &base_ref,
1896 head_sha.as_deref(),
1897 &[],
1898 ))
1899 } else {
1900 None
1901 };
1902
1903 AuditResult {
1904 verdict: AuditVerdict::Pass,
1905 summary: AuditSummary {
1906 dead_code_issues: 0,
1907 dead_code_has_errors: false,
1908 complexity_findings: 0,
1909 max_cyclomatic: None,
1910 duplication_clone_groups: 0,
1911 },
1912 attribution: AuditAttribution {
1913 gate: opts.gate,
1914 ..AuditAttribution::default()
1915 },
1916 base_snapshot: None,
1917 base_snapshot_skipped: false,
1918 changed_files_count: 0,
1919 changed_files: Vec::new(),
1920 base_ref,
1921 base_description,
1922 head_sha,
1923 output: opts.output,
1924 performance: opts.performance,
1925 check: None,
1926 dupes: None,
1927 health: None,
1928 elapsed,
1929 review_deltas: None,
1930 weakening_signals: Vec::new(),
1931 routing: None,
1932 decision_surface: None,
1933 graph_snapshot_hash,
1934 change_anchors: Vec::new(),
1935 }
1936}
1937
1938fn run_audit_check<'a>(
1940 opts: &'a AuditOptions<'a>,
1941 changed_since: Option<&'a str>,
1942 retain_modules_for_health: bool,
1943) -> Result<Option<CheckResult>, ExitCode> {
1944 let filters = IssueFilters::default();
1945 let retain_modules_for_health = retain_modules_for_health || opts.brief;
1950 let trace_opts = TraceOptions {
1951 trace_export: None,
1952 trace_file: None,
1953 trace_dependency: None,
1954 impact_closure: None,
1955 performance: opts.performance,
1956 };
1957 match crate::check::execute_check(&CheckOptions {
1958 root: opts.root,
1959 config_path: opts.config_path,
1960 output: opts.output,
1961 no_cache: opts.no_cache,
1962 threads: opts.threads,
1963 quiet: opts.quiet,
1964 fail_on_issues: false,
1965 filters: &filters,
1966 changed_since,
1967 diff_index: None,
1968 use_shared_diff_index: true,
1969 baseline: opts.dead_code_baseline,
1970 save_baseline: None,
1971 sarif_file: None,
1972 production: opts.production_dead_code.unwrap_or(opts.production),
1973 production_override: opts.production_dead_code,
1974 workspace: opts.workspace,
1975 changed_workspaces: opts.changed_workspaces,
1976 group_by: opts.group_by,
1977 include_dupes: false,
1978 trace_opts: &trace_opts,
1979 explain: opts.explain,
1980 top: None,
1981 file: &[],
1982 include_entry_exports: opts.include_entry_exports,
1983 summary: false,
1984 regression_opts: crate::regression::RegressionOpts {
1985 fail_on_regression: false,
1986 tolerance: crate::regression::Tolerance::Absolute(0),
1987 regression_baseline_file: None,
1988 save_target: crate::regression::SaveRegressionTarget::None,
1989 scoped: true,
1990 quiet: opts.quiet,
1991 output: opts.output,
1992 },
1993 retain_modules_for_health,
1994 defer_performance: false,
1995 }) {
1996 Ok(r) => Ok(Some(r)),
1997 Err(code) => Err(code),
1998 }
1999}
2000
2001fn run_audit_dupes<'a>(
2007 opts: &'a AuditOptions<'a>,
2008 changed_since: Option<&'a str>,
2009 changed_files: Option<&'a FxHashSet<PathBuf>>,
2010 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2011) -> Result<Option<DupesResult>, ExitCode> {
2012 let dupes_cfg = match crate::load_config_for_analysis(
2013 opts.root,
2014 opts.config_path,
2015 crate::ConfigLoadOptions {
2016 output: opts.output,
2017 no_cache: opts.no_cache,
2018 threads: opts.threads,
2019 production_override: opts
2020 .production_dupes
2021 .or_else(|| opts.production.then_some(true)),
2022 quiet: opts.quiet,
2023 },
2024 fallow_config::ProductionAnalysis::Dupes,
2025 ) {
2026 Ok(c) => c.duplicates,
2027 Err(code) => return Err(code),
2028 };
2029 let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
2030 let dupes_run = if let Some(files) = pre_discovered {
2031 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2032 } else {
2033 crate::dupes::execute_dupes(&dupes_opts)
2034 };
2035 match dupes_run {
2036 Ok(r) => Ok(Some(r)),
2037 Err(code) => Err(code),
2038 }
2039}
2040
2041fn build_audit_dupes_options<'a>(
2043 opts: &'a AuditOptions<'a>,
2044 changed_since: Option<&'a str>,
2045 changed_files: Option<&'a FxHashSet<PathBuf>>,
2046 dupes_cfg: &fallow_config::DuplicatesConfig,
2047) -> DupesOptions<'a> {
2048 DupesOptions {
2049 root: opts.root,
2050 config_path: opts.config_path,
2051 output: opts.output,
2052 no_cache: opts.no_cache,
2053 threads: opts.threads,
2054 quiet: opts.quiet,
2055 mode: Some(DupesMode::from(dupes_cfg.mode)),
2056 min_tokens: Some(dupes_cfg.min_tokens),
2057 min_lines: Some(dupes_cfg.min_lines),
2058 min_occurrences: Some(dupes_cfg.min_occurrences),
2059 threshold: Some(dupes_cfg.threshold),
2060 skip_local: dupes_cfg.skip_local,
2061 cross_language: dupes_cfg.cross_language,
2062 ignore_imports: Some(dupes_cfg.ignore_imports),
2063 top: None,
2064 baseline_path: opts.dupes_baseline,
2065 save_baseline_path: None,
2066 production: opts.production_dupes.unwrap_or(opts.production),
2067 production_override: opts.production_dupes,
2068 trace: None,
2069 changed_since,
2070 diff_index: None,
2071 use_shared_diff_index: true,
2072 changed_files,
2073 workspace: opts.workspace,
2074 changed_workspaces: opts.changed_workspaces,
2075 explain: opts.explain,
2076 explain_skipped: opts.explain_skipped,
2077 summary: false,
2078 group_by: opts.group_by,
2079 performance: false,
2080 }
2081}
2082
2083fn run_audit_health<'a>(
2085 opts: &'a AuditOptions<'a>,
2086 changed_since: Option<&'a str>,
2087 shared_parse: Option<fallow_engine::health::HealthSharedParseData>,
2088) -> Result<Option<HealthResult>, ExitCode> {
2089 let runtime_coverage = match opts.runtime_coverage {
2090 Some(path) => match crate::health::coverage::prepare_options(
2091 path,
2092 opts.min_invocations_hot,
2093 None,
2094 None,
2095 opts.output,
2096 ) {
2097 Ok(options) => Some(options),
2098 Err(code) => return Err(code),
2099 },
2100 None => None,
2101 };
2102
2103 let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
2104 let health_run = if let Some(shared) = shared_parse {
2105 crate::health::execute_health_with_shared_parse(&health_opts, shared)
2106 } else {
2107 crate::health::execute_health(&health_opts)
2108 };
2109 match health_run {
2110 Ok(r) => Ok(Some(r)),
2111 Err(code) => Err(code),
2112 }
2113}
2114
2115fn build_audit_health_options<'a>(
2118 opts: &'a AuditOptions<'a>,
2119 changed_since: Option<&'a str>,
2120 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
2121) -> HealthOptions<'a> {
2122 HealthOptions {
2123 root: opts.root,
2124 config_path: opts.config_path,
2125 output: opts.output,
2126 no_cache: opts.no_cache,
2127 threads: opts.threads,
2128 quiet: opts.quiet,
2129 thresholds: fallow_engine::health::HealthThresholdOverrides {
2130 max_cyclomatic: None,
2131 max_cognitive: None,
2132 max_crap: opts.max_crap,
2133 },
2134 top: None,
2135 sort: fallow_engine::health::HealthSort::Cyclomatic,
2136 production: opts.production_health.unwrap_or(opts.production),
2137 production_override: opts.production_health,
2138 changed_since,
2139 diff_index: None,
2140 use_shared_diff_index: true,
2141 workspace: opts.workspace,
2142 changed_workspaces: opts.changed_workspaces,
2143 baseline: opts.health_baseline,
2144 save_baseline: None,
2145 complexity: true,
2146 file_scores: false,
2147 coverage_gaps: false,
2148 config_activates_coverage_gaps: false,
2149 hotspots: false,
2150 ownership: false,
2151 ownership_emails: None,
2152 targets: false,
2153 css: opts.css,
2158 css_deep: opts.css_deep,
2159 force_full: false,
2160 score_only_output: false,
2161 enforce_coverage_gap_gate: false,
2162 effort: None,
2163 score: false,
2164 gates: fallow_engine::health::HealthGateOptions::default(),
2165 since: None,
2166 min_commits: None,
2167 explain: opts.explain,
2168 summary: false,
2169 save_snapshot: None,
2170 trend: false,
2171 coverage_inputs: fallow_engine::health::HealthCoverageInputs {
2172 coverage: opts.coverage,
2173 coverage_root: opts.coverage_root,
2174 },
2175 performance: opts.performance,
2176 runtime_coverage,
2177 churn_file: None,
2178 complexity_breakdown: false,
2179 group_by: opts.group_by.map(Into::into),
2180 }
2181}
2182
2183#[path = "audit_output.rs"]
2184mod output;
2185
2186pub use output::audit_json_header_input;
2187pub use output::{
2188 insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
2189 print_audit_findings, print_audit_result,
2190};
2191
2192pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
2198 if let Err(e) = fallow_engine::health::validate_coverage_root_absolute(opts.coverage_root) {
2199 return emit_error(&e, 2, opts.output);
2200 }
2201 let coverage_resolved = opts
2202 .coverage
2203 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2204 let runtime_coverage_resolved = opts
2205 .runtime_coverage
2206 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2207 let resolved_opts = AuditOptions {
2208 coverage: coverage_resolved.as_deref(),
2209 runtime_coverage: runtime_coverage_resolved.as_deref(),
2210 ..*opts
2211 };
2212 match execute_audit(&resolved_opts) {
2213 Ok(result) => {
2214 record_audit_impact(opts, gate_marker, &result);
2215 print_audit_command_result(opts, &result)
2216 }
2217 Err(code) => code,
2218 }
2219}
2220
2221fn record_audit_impact(opts: &AuditOptions<'_>, gate_marker: Option<&str>, result: &AuditResult) {
2222 let mut findings = result
2223 .check
2224 .as_ref()
2225 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
2226 .unwrap_or_default();
2227 if let Some(health) = result.health.as_ref() {
2228 findings.extend(crate::impact::collect_complexity_findings(&health.report));
2229 }
2230 let clones = result
2231 .dupes
2232 .as_ref()
2233 .map(|d| crate::impact::collect_clone_findings(&d.report))
2234 .unwrap_or_default();
2235 let empty_supps: Vec<fallow_types::results::ActiveSuppression> = Vec::new();
2236 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
2237 c.results.active_suppressions.as_slice()
2238 });
2239 let attribution = crate::impact::AttributionInput {
2240 root: opts.root,
2241 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
2242 findings,
2243 clones,
2244 suppressions,
2245 };
2246 crate::impact::record_audit_run(
2247 opts.root,
2248 &result.summary,
2249 &crate::impact::AuditRunRecord {
2250 verdict: result.verdict,
2251 gate: gate_marker.is_some(),
2252 git_sha: result.head_sha.as_deref(),
2253 version: env!("CARGO_PKG_VERSION"),
2254 timestamp: &crate::vital_signs::chrono_timestamp(),
2255 attribution: Some(&attribution),
2256 },
2257 );
2258}
2259
2260fn print_audit_command_result(opts: &AuditOptions<'_>, result: &AuditResult) -> ExitCode {
2261 if opts.walkthrough_guide {
2262 return crate::audit_brief::print_walkthrough_guide_result(result);
2263 }
2264 if opts.walkthrough {
2265 return crate::audit_brief::print_walkthrough_human_result(
2266 result,
2267 opts.root,
2268 opts.cache_dir,
2269 opts.mark_viewed,
2270 opts.show_cleared,
2271 opts.quiet,
2272 );
2273 }
2274 if let Some(path) = opts.walkthrough_file {
2275 return crate::audit_brief::print_walkthrough_file_result(result, path);
2276 }
2277 if opts.brief {
2278 return crate::audit_brief::print_brief_result(
2279 result,
2280 opts.quiet,
2281 opts.explain,
2282 opts.show_deprioritized,
2283 );
2284 }
2285 print_audit_result(result, opts.quiet, opts.explain)
2286}
2287
2288#[must_use]
2297pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
2298 let brief_opts = AuditOptions {
2300 brief: true,
2301 ..*opts
2302 };
2303 match execute_audit(&brief_opts) {
2304 Ok(result) => crate::audit_brief::print_decision_surface_result(&result, opts.quiet),
2305 Err(code) => code,
2306 }
2307}
2308
2309#[cfg(test)]
2310#[path = "audit_tests.rs"]
2311mod tests;