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::{BaseWorktree, git_toplevel, sweep_old_reusable_caches};
16use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
17use crate::dupes::{DupesMode, DupesOptions, DupesResult};
18use crate::error::emit_error;
19use crate::health::{HealthOptions, HealthResult};
20
21pub struct AuditResult {
23 pub verdict: AuditVerdict,
24 pub summary: AuditSummary,
25 pub attribution: AuditAttribution,
26 pub base_snapshot: Option<AuditKeySnapshot>,
31 pub base_snapshot_skipped: bool,
32 pub changed_files_count: usize,
33 pub changed_files: Vec<PathBuf>,
37 pub base_ref: String,
38 pub base_description: Option<String>,
43 pub head_sha: Option<String>,
44 pub output: OutputFormat,
45 pub performance: bool,
46 pub check: Option<CheckResult>,
47 pub dupes: Option<DupesResult>,
48 pub health: Option<HealthResult>,
49 pub elapsed: Duration,
50 pub review_deltas: Option<crate::audit_brief::ReviewDeltas>,
54 pub weakening_signals: Vec<weakening::WeakeningSignal>,
55 pub routing: Option<routing::RoutingFacts>,
56 pub decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
60 pub graph_snapshot_hash: Option<String>,
67 pub change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
73}
74
75pub struct AuditOptions<'a> {
76 pub root: &'a std::path::Path,
77 pub config_path: &'a Option<std::path::PathBuf>,
78 pub cache_dir: &'a std::path::Path,
79 pub output: OutputFormat,
80 pub no_cache: bool,
81 pub threads: usize,
82 pub quiet: bool,
83 pub allow_remote_extends: bool,
84 pub changed_since: Option<&'a str>,
85 pub production: bool,
86 pub production_dead_code: Option<bool>,
87 pub production_health: Option<bool>,
88 pub production_dupes: Option<bool>,
89 pub workspace: Option<&'a [String]>,
90 pub changed_workspaces: Option<&'a str>,
91 pub explain: bool,
92 pub explain_skipped: bool,
93 pub performance: bool,
94 pub group_by: Option<crate::GroupBy>,
95 pub dead_code_baseline: Option<&'a std::path::Path>,
97 pub health_baseline: Option<&'a std::path::Path>,
99 pub dupes_baseline: Option<&'a std::path::Path>,
101 pub max_crap: Option<f64>,
104 pub coverage: Option<&'a std::path::Path>,
106 pub coverage_root: Option<&'a std::path::Path>,
108 pub gate: AuditGate,
109 pub include_entry_exports: bool,
111 pub css: bool,
115 pub css_deep: bool,
118 pub runtime_coverage: Option<&'a std::path::Path>,
124 pub min_invocations_hot: u64,
126 pub brief: bool,
131 pub max_decisions: usize,
135 pub walkthrough_guide: bool,
138 pub walkthrough: bool,
141 pub mark_viewed: &'a [std::path::PathBuf],
144 pub show_cleared: bool,
146 pub walkthrough_file: Option<&'a std::path::Path>,
149 pub show_deprioritized: bool,
154}
155
156#[path = "audit_base_ref.rs"]
157mod base_ref;
158#[path = "audit_cache.rs"]
159mod cache;
160
161#[cfg(test)]
162use base_ref::{auto_detect_base_ref, parse_audit_base_override};
163use base_ref::{get_head_sha, resolve_base_ref};
164#[cfg(test)]
165use cache::{
166 AUDIT_BASE_SNAPSHOT_CACHE_VERSION, CachedAuditKeySnapshot, audit_base_snapshot_cache_dir,
167 audit_base_snapshot_cache_file, cached_from_snapshot, config_file_fingerprint,
168 ensure_audit_base_snapshot_cache_dir, snapshot_from_cached,
169};
170use cache::{
171 AuditBaseSnapshotCacheKey, audit_base_snapshot_cache_key, load_cached_base_snapshot,
172 save_cached_base_snapshot, sorted_keys,
173};
174
175fn styling_finding_gates(rules: &fallow_config::RulesConfig, code: &str) -> bool {
180 let severity = match code {
181 "css-token-drift" => rules.css_token_drift,
182 "css-duplicate-block" => rules.css_duplicate_block,
183 "css-selector-complexity" => rules.css_selector_complexity,
184 "css-dead-surface" => rules.css_dead_surface,
185 "css-broken-reference" => rules.css_broken_reference,
186 _ => fallow_config::Severity::Warn,
187 };
188 severity == fallow_config::Severity::Error
189}
190
191fn compute_verdict(
192 check: Option<&CheckResult>,
193 dupes: Option<&DupesResult>,
194 health: Option<&HealthResult>,
195) -> AuditVerdict {
196 let mut has_errors = false;
197 let mut has_warnings = false;
198
199 if let Some(result) = check {
200 if crate::check::has_error_severity_issues(
201 &result.results,
202 &result.config.rules,
203 Some(&result.config),
204 ) {
205 has_errors = true;
206 } else if result.results.total_issues() > 0 {
207 has_warnings = true;
208 }
209 }
210
211 if let Some(result) = health
212 && !result.report.findings.is_empty()
213 {
214 has_errors = true;
215 }
216
217 if let Some(result) = health
222 && result
223 .report
224 .styling_findings
225 .iter()
226 .any(|finding| styling_finding_gates(&result.config.rules, &finding.code))
227 {
228 has_errors = true;
229 }
230
231 if let Some(result) = dupes
232 && !result.report.clone_groups.is_empty()
233 {
234 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
235 has_errors = true;
236 } else {
237 has_warnings = true;
238 }
239 }
240
241 if has_errors {
242 AuditVerdict::Fail
243 } else if has_warnings {
244 AuditVerdict::Warn
245 } else {
246 AuditVerdict::Pass
247 }
248}
249
250fn build_summary(
251 check: Option<&CheckResult>,
252 dupes: Option<&DupesResult>,
253 health: Option<&HealthResult>,
254) -> AuditSummary {
255 let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
256 let dead_code_has_errors = check.is_some_and(|r| {
257 crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
258 });
259 let complexity_findings = health.map_or(0, |r| r.report.findings.len());
260 let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
261 let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
262
263 AuditSummary {
264 dead_code_issues,
265 dead_code_has_errors,
266 complexity_findings,
267 max_cyclomatic,
268 duplication_clone_groups,
269 }
270}
271
272fn compute_audit_attribution(
273 check: Option<&CheckResult>,
274 dupes: Option<&DupesResult>,
275 health: Option<&HealthResult>,
276 base: Option<&AuditKeySnapshot>,
277 gate: AuditGate,
278) -> AuditAttribution {
279 let dead_code = check
280 .map(|r| {
281 count_introduced(
282 &dead_code_keys(&r.results, &r.config.root),
283 base.map(|b| &b.dead_code),
284 )
285 })
286 .unwrap_or_default();
287 let complexity = health
288 .map(|r| {
289 count_introduced(
290 &health_keys(&r.report, &r.config.root),
291 base.map(|b| &b.health),
292 )
293 })
294 .unwrap_or_default();
295 let duplication = dupes
296 .map(|r| {
297 count_introduced(
298 &dupes_keys(&r.report, &r.config.root),
299 base.map(|b| &b.dupes),
300 )
301 })
302 .unwrap_or_default();
303
304 AuditAttribution {
305 gate,
306 dead_code_introduced: dead_code.0,
307 dead_code_inherited: dead_code.1,
308 complexity_introduced: complexity.0,
309 complexity_inherited: complexity.1,
310 duplication_introduced: duplication.0,
311 duplication_inherited: duplication.1,
312 }
313}
314
315fn compute_introduced_verdict(
316 check: Option<&CheckResult>,
317 dupes: Option<&DupesResult>,
318 health: Option<&HealthResult>,
319 base: Option<&AuditKeySnapshot>,
320) -> AuditVerdict {
321 let mut has_errors = false;
322 let mut has_warnings = false;
323
324 if let Some(result) = check {
325 let (errors, warnings) = introduced_check_verdict(result, base);
326 has_errors |= errors;
327 has_warnings |= warnings;
328 }
329 if let Some(result) = health {
330 has_errors |= introduced_health_has_errors(result, base);
331 }
332 if let Some(result) = health
333 && result
334 .report
335 .styling_findings
336 .iter()
337 .any(|finding| introduced_styling_finding_gates(result, base, finding))
338 {
339 has_errors = true;
340 }
341 if let Some(result) = dupes {
342 let (errors, warnings) = introduced_dupes_verdict(result, base);
343 has_errors |= errors;
344 has_warnings |= warnings;
345 }
346
347 if has_errors {
348 AuditVerdict::Fail
349 } else if has_warnings {
350 AuditVerdict::Warn
351 } else {
352 AuditVerdict::Pass
353 }
354}
355
356fn introduced_check_verdict(result: &CheckResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
358 let base_keys = base.map(|b| &b.dead_code);
359 let mut introduced = result.results.clone();
360 retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
361 if crate::check::has_error_severity_issues(
362 &introduced,
363 &result.config.rules,
364 Some(&result.config),
365 ) {
366 (true, false)
367 } else if introduced.total_issues() > 0 {
368 (false, true)
369 } else {
370 (false, false)
371 }
372}
373
374fn introduced_health_has_errors(result: &HealthResult, base: Option<&AuditKeySnapshot>) -> bool {
376 let base_keys = base.map(|b| &b.health);
377 result.report.findings.iter().any(|finding| {
378 !base_keys
379 .is_some_and(|keys| keys.contains(&health_finding_key(finding, &result.config.root)))
380 })
381}
382
383fn introduced_styling_finding_gates(
384 result: &HealthResult,
385 base: Option<&AuditKeySnapshot>,
386 finding: &fallow_output::StylingFinding,
387) -> bool {
388 styling_finding_gates(&result.config.rules, &finding.code)
389 && !base.is_some_and(|snapshot| {
390 snapshot
391 .styling
392 .contains(&styling_finding_key(finding, &result.config.root))
393 })
394}
395
396fn introduced_dupes_verdict(result: &DupesResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
398 let base_keys = base.map(|b| &b.dupes);
399 let introduced = result
400 .report
401 .clone_groups
402 .iter()
403 .filter(|group| {
404 !base_keys
405 .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
406 })
407 .count();
408 if introduced == 0 {
409 return (false, false);
410 }
411 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
412 (true, false)
413 } else {
414 (false, true)
415 }
416}
417
418pub struct AuditKeySnapshot {
419 dead_code: FxHashSet<String>,
420 health: FxHashSet<String>,
421 styling: FxHashSet<String>,
422 dupes: FxHashSet<String>,
423 boundary_edges: FxHashSet<String>,
427 cycles: FxHashSet<String>,
429 public_api: FxHashSet<String>,
432}
433
434fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
435 let Some(base) = base else {
436 return (0, 0);
437 };
438 keys.iter().fold((0, 0), |(introduced, inherited), key| {
439 if base.contains(key) {
440 (introduced, inherited + 1)
441 } else {
442 (introduced + 1, inherited)
443 }
444 })
445}
446
447fn ambient_git_env_hint() -> Option<String> {
452 use fallow_engine::changed_files::AMBIENT_GIT_ENV_VARS;
453 for var in AMBIENT_GIT_ENV_VARS {
454 if let Ok(value) = std::env::var(var)
455 && !value.is_empty()
456 {
457 return Some(format!(
458 "{var}={value} is set in the environment; if fallow is being \
459invoked from a git hook this can interfere with worktree operations. Re-run \
460with `env -u {var} fallow audit` to confirm."
461 ));
462 }
463 }
464 None
465}
466
467fn compute_base_snapshot(
468 opts: &AuditOptions<'_>,
469 base_ref: &str,
470 changed_files: &FxHashSet<PathBuf>,
471 base_sha: Option<&str>,
472) -> Result<AuditKeySnapshot, ExitCode> {
473 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
474 use std::fmt::Write as _;
475 let mut message =
476 format!("could not create a temporary worktree for base ref '{base_ref}'");
477 if let Some(hint) = ambient_git_env_hint() {
478 let _ = write!(message, "\n hint: {hint}");
479 }
480 return Err(emit_error(&message, 2, opts.output));
481 };
482 let base_root = base_analysis_root(opts.root, worktree.path());
483 let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
484 let current_config_path = opts
485 .config_path
486 .clone()
487 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
488 let base_opts =
489 build_base_audit_options(opts, &base_root, ¤t_config_path, &base_cache_dir);
490
491 let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
492 let check_production = opts.production_dead_code.unwrap_or(opts.production);
493 let health_production = opts.production_health.unwrap_or(opts.production);
494 let share_dead_code_parse_with_health = check_production == health_production;
495
496 let (check_res, dupes_res) = rayon::join(
497 || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
498 || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
499 );
500 let mut check = check_res?;
501 let dupes = dupes_res?;
502 let base_public_api = if opts.brief {
506 public_api_keys_from_check(check.as_ref(), &base_root)
507 } else {
508 FxHashSet::default()
509 };
510 let shared_parse = if share_dead_code_parse_with_health {
511 check.as_mut().and_then(|r| r.shared_parse.take())
512 } else {
513 None
514 };
515 let health = run_audit_health(&base_opts, None, shared_parse)?;
516 if let Some(ref mut check) = check {
517 check.shared_parse = None;
518 }
519
520 Ok(snapshot_from_results(
521 check.as_ref(),
522 dupes.as_ref(),
523 health.as_ref(),
524 base_public_api,
525 ))
526}
527
528fn snapshot_from_results(
534 check: Option<&CheckResult>,
535 dupes: Option<&DupesResult>,
536 health: Option<&HealthResult>,
537 public_api: FxHashSet<String>,
538) -> AuditKeySnapshot {
539 let (boundary_edges, cycles) = check.map_or_else(
540 || (FxHashSet::default(), FxHashSet::default()),
541 |r| {
542 (
543 review_deltas::boundary_edge_keys(&r.results.boundary_violations),
544 review_deltas::cycle_keys(&r.results.circular_dependencies, &r.config.root),
545 )
546 },
547 );
548 AuditKeySnapshot {
549 dead_code: check.map_or_else(FxHashSet::default, |r| {
550 dead_code_keys(&r.results, &r.config.root)
551 }),
552 health: health.map_or_else(FxHashSet::default, |r| {
553 health_keys(&r.report, &r.config.root)
554 }),
555 styling: health.map_or_else(FxHashSet::default, |r| {
556 styling_keys(&r.report, &r.config.root)
557 }),
558 dupes: dupes.map_or_else(FxHashSet::default, |r| {
559 dupes_keys(&r.report, &r.config.root)
560 }),
561 boundary_edges,
562 cycles,
563 public_api,
564 }
565}
566
567fn public_api_keys_from_check(check: Option<&CheckResult>, root: &Path) -> FxHashSet<String> {
572 let Some(check) = check else {
573 return FxHashSet::default();
574 };
575 let Some(graph) = check
576 .shared_parse
577 .as_ref()
578 .and_then(|sp| sp.analysis_output.as_ref())
579 .and_then(|out| out.graph.as_ref())
580 else {
581 return FxHashSet::default();
582 };
583 review_deltas::public_export_keys_for(graph, &check.config, &check.workspaces, root)
584}
585
586#[expect(
588 clippy::ref_option,
589 reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
590)]
591fn build_base_audit_options<'a>(
592 opts: &AuditOptions<'a>,
593 base_root: &'a Path,
594 current_config_path: &'a Option<PathBuf>,
595 base_cache_dir: &'a Path,
596) -> AuditOptions<'a> {
597 AuditOptions {
598 root: base_root,
599 config_path: current_config_path,
600 cache_dir: base_cache_dir,
601 output: opts.output,
602 no_cache: opts.no_cache,
603 threads: opts.threads,
604 quiet: true,
605 allow_remote_extends: opts.allow_remote_extends,
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_audit_worktree_path, reusable_worktree_last_used_path,
919 reusable_worktree_lock_path, reusable_worktree_sha_path, sweep_orphan_audit_worktrees_in,
920 touch_last_used, unregister_worktree,
921};
922
923pub use fallow_api::audit_keys as keys;
924
925#[path = "audit_review_deltas.rs"]
926pub mod review_deltas;
927
928#[path = "audit_weakening.rs"]
929pub mod weakening;
930
931#[path = "audit_routing.rs"]
932pub mod routing;
933
934use keys::{
935 dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
936 retain_introduced_dead_code, styling_finding_key, styling_keys,
937};
938
939struct HeadAnalyses {
940 check: Option<CheckResult>,
941 dupes: Option<DupesResult>,
942 health: Option<HealthResult>,
943}
944
945type HeadAndBaseResult = (
948 Result<HeadAnalyses, ExitCode>,
949 Option<Result<AuditKeySnapshot, ExitCode>>,
950);
951
952fn run_audit_head_and_base(
955 opts: &AuditOptions<'_>,
956 changed_since: Option<&str>,
957 changed_files: &FxHashSet<PathBuf>,
958 base_ref: &str,
959 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
960 run_base: bool,
961) -> HeadAndBaseResult {
962 if run_base {
963 let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
964 let (h, b) = rayon::join(
965 || run_audit_head_analyses(opts, changed_since, changed_files),
966 || compute_base_snapshot(opts, base_ref, changed_files, base_sha),
967 );
968 (h, Some(b))
969 } else {
970 (
971 run_audit_head_analyses(opts, changed_since, changed_files),
972 None,
973 )
974 }
975}
976
977struct AuditResultParts {
978 verdict: AuditVerdict,
979 summary: AuditSummary,
980 attribution: AuditAttribution,
981 base_snapshot: Option<AuditKeySnapshot>,
982 base_snapshot_skipped: bool,
983 changed_files_count: usize,
984 changed_files: FxHashSet<PathBuf>,
985 base_ref: String,
986 base_description: Option<String>,
987 head_sha: Option<String>,
988 output: OutputFormat,
989 performance: bool,
990 check: Option<CheckResult>,
991 dupes: Option<DupesResult>,
992 health: Option<HealthResult>,
993 elapsed: Duration,
994 review_deltas: Option<crate::audit_brief::ReviewDeltas>,
995 weakening_signals: Vec<weakening::WeakeningSignal>,
996 routing: Option<routing::RoutingFacts>,
997 decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
998 graph_snapshot_hash: Option<String>,
999 change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
1000}
1001
1002#[derive(Default)]
1003struct AuditBriefData {
1004 review_deltas: Option<crate::audit_brief::ReviewDeltas>,
1005 weakening_signals: Vec<weakening::WeakeningSignal>,
1006 routing: Option<routing::RoutingFacts>,
1007 decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
1008 graph_snapshot_hash: Option<String>,
1009 change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
1010}
1011
1012#[derive(Clone, Copy)]
1013struct AuditBriefDataInput<'a> {
1014 opts: &'a AuditOptions<'a>,
1015 check: Option<&'a CheckResult>,
1016 dupes: Option<&'a DupesResult>,
1017 health: Option<&'a HealthResult>,
1018 base_snapshot: Option<&'a AuditKeySnapshot>,
1019 changed_files: &'a FxHashSet<PathBuf>,
1020 base_ref: &'a str,
1021 head_sha: Option<&'a str>,
1022}
1023
1024fn run_audit_head_analyses(
1031 opts: &AuditOptions<'_>,
1032 changed_since: Option<&str>,
1033 changed_files: &FxHashSet<PathBuf>,
1034) -> Result<HeadAnalyses, ExitCode> {
1035 let check_production = opts.production_dead_code.unwrap_or(opts.production);
1036 let health_production = opts.production_health.unwrap_or(opts.production);
1037 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
1038 let share_dead_code_parse_with_health = check_production == health_production;
1039 let share_dead_code_files_with_dupes =
1040 share_dead_code_parse_with_health && check_production == dupes_production;
1041
1042 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
1043 let dupes_files = if share_dead_code_files_with_dupes {
1044 check
1045 .as_ref()
1046 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
1047 } else {
1048 None
1049 };
1050 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
1051 if opts.brief
1056 && let Some(ref mut check) = check
1057 {
1058 check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
1059 check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
1060 check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
1061 check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
1062 check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
1063 check.internal_consumers =
1064 compute_brief_internal_consumers(opts.root, check, changed_files);
1065 }
1066 let shared_parse = if share_dead_code_parse_with_health {
1067 check.as_mut().and_then(|r| r.shared_parse.take())
1068 } else {
1069 None
1070 };
1071 let health = run_audit_health(opts, changed_since, shared_parse)?;
1072 Ok(HeadAnalyses {
1073 check,
1074 dupes,
1075 health,
1076 })
1077}
1078
1079fn compute_brief_impact_closure(
1087 root: &std::path::Path,
1088 check: &CheckResult,
1089 changed_files: &FxHashSet<PathBuf>,
1090) -> Option<fallow_engine::module_graph::ImpactClosurePaths> {
1091 let graph = check
1092 .shared_parse
1093 .as_ref()
1094 .and_then(|sp| sp.analysis_output.as_ref())
1095 .and_then(|out| out.graph.as_ref())?;
1096
1097 fallow_engine::module_graph::impact_closure_for_changed_paths(graph, root, changed_files)
1098}
1099
1100fn compute_brief_partition_order(
1108 root: &std::path::Path,
1109 check: &CheckResult,
1110 changed_files: &FxHashSet<PathBuf>,
1111) -> Option<fallow_engine::module_graph::PartitionOrderPaths> {
1112 let graph = check
1113 .shared_parse
1114 .as_ref()
1115 .and_then(|sp| sp.analysis_output.as_ref())
1116 .and_then(|out| out.graph.as_ref())?;
1117
1118 fallow_engine::module_graph::partition_order_for_changed_paths(graph, root, changed_files)
1119}
1120
1121fn compute_brief_export_lines(
1126 root: &std::path::Path,
1127 check: &CheckResult,
1128 changed_files: &FxHashSet<PathBuf>,
1129) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
1130 let graph = check
1131 .shared_parse
1132 .as_ref()
1133 .and_then(|sp| sp.analysis_output.as_ref())
1134 .and_then(|out| out.graph.as_ref())?;
1135
1136 fallow_engine::module_graph::export_lines_for_changed_paths(graph, root, changed_files)
1137}
1138
1139fn compute_brief_internal_consumers(
1148 root: &std::path::Path,
1149 check: &CheckResult,
1150 changed_files: &FxHashSet<PathBuf>,
1151) -> Option<FxHashMap<String, u64>> {
1152 let graph = check
1153 .shared_parse
1154 .as_ref()
1155 .and_then(|sp| sp.analysis_output.as_ref())
1156 .and_then(|out| out.graph.as_ref())?;
1157
1158 fallow_engine::module_graph::internal_consumers_for_changed_paths(graph, root, changed_files)
1159}
1160
1161fn compute_brief_focus_facts(
1171 root: &std::path::Path,
1172 check: &CheckResult,
1173 changed_files: &FxHashSet<PathBuf>,
1174) -> Option<Vec<fallow_engine::module_graph::FocusFileFactsPaths>> {
1175 let graph = check
1176 .shared_parse
1177 .as_ref()
1178 .and_then(|sp| sp.analysis_output.as_ref())
1179 .and_then(|out| out.graph.as_ref())?;
1180
1181 fallow_engine::module_graph::focus_facts_for_changed_paths(graph, root, changed_files)
1182}
1183
1184pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1186 let start = Instant::now();
1187
1188 let (base_ref, base_description) = resolve_base_ref(opts)?;
1189
1190 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1191 return Err(emit_error(
1192 &format!(
1193 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1194 ),
1195 2,
1196 opts.output,
1197 ));
1198 };
1199 let changed_files_count = changed_files.len();
1200
1201 if changed_files.is_empty() {
1202 return Ok(empty_audit_result(
1203 base_ref,
1204 base_description,
1205 opts,
1206 start.elapsed(),
1207 ));
1208 }
1209
1210 sweep_old_reusable_caches(
1214 opts.root,
1215 crate::base_worktree::resolve_cache_max_age_with_options(
1216 opts.root,
1217 opts.config_path.as_ref(),
1218 opts.allow_remote_extends,
1219 ),
1220 opts.quiet,
1221 );
1222
1223 let changed_since = Some(base_ref.as_str());
1224
1225 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1226 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1227 let base_cache_key = if needs_real_base_snapshot {
1228 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1229 } else {
1230 None
1231 };
1232 let cached_base_snapshot = base_cache_key
1233 .as_ref()
1234 .and_then(|key| load_cached_base_snapshot(opts, key));
1235
1236 let (head_res, base_res) = run_audit_head_and_base(
1237 opts,
1238 changed_since,
1239 &changed_files,
1240 &base_ref,
1241 base_cache_key.as_ref(),
1242 needs_real_base_snapshot && cached_base_snapshot.is_none(),
1243 );
1244
1245 assemble_audit_result(AuditAssemblyInput {
1246 opts,
1247 head_res,
1248 base_res,
1249 cached_base_snapshot,
1250 base_cache_key,
1251 changed_files,
1252 changed_files_count,
1253 base_ref,
1254 base_description,
1255 start,
1256 })
1257}
1258
1259struct AuditAssemblyInput<'a> {
1261 opts: &'a AuditOptions<'a>,
1262 head_res: Result<HeadAnalyses, ExitCode>,
1263 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1264 cached_base_snapshot: Option<AuditKeySnapshot>,
1265 base_cache_key: Option<AuditBaseSnapshotCacheKey>,
1266 changed_files: FxHashSet<PathBuf>,
1267 changed_files_count: usize,
1268 base_ref: String,
1269 base_description: Option<String>,
1270 start: Instant,
1271}
1272
1273fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
1276 let opts = input.opts;
1277 let head = input.head_res?;
1278 let mut check_result = head.check;
1279 let dupes_result = head.dupes;
1280 let health_result = head.health;
1281
1282 let (base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
1283 opts,
1284 input.cached_base_snapshot,
1285 input.base_res,
1286 input.base_cache_key.as_ref(),
1287 CurrentAnalysisRefs {
1288 check: check_result.as_ref(),
1289 dupes: dupes_result.as_ref(),
1290 health: health_result.as_ref(),
1291 },
1292 )?;
1293 drop_check_shared_parse(&mut check_result);
1294 let (attribution, verdict, summary) = compute_audit_outcome(
1295 opts.gate,
1296 check_result.as_ref(),
1297 dupes_result.as_ref(),
1298 health_result.as_ref(),
1299 base_snapshot.as_ref(),
1300 );
1301
1302 let head_sha = get_head_sha(opts.root);
1303 let brief = compute_audit_brief_data(AuditBriefDataInput {
1304 opts,
1305 check: check_result.as_ref(),
1306 dupes: dupes_result.as_ref(),
1307 health: health_result.as_ref(),
1308 base_snapshot: base_snapshot.as_ref(),
1309 changed_files: &input.changed_files,
1310 base_ref: &input.base_ref,
1311 head_sha: head_sha.as_deref(),
1312 });
1313
1314 Ok(build_audit_result(AuditResultParts {
1315 verdict,
1316 summary,
1317 attribution,
1318 base_snapshot,
1319 base_snapshot_skipped,
1320 changed_files_count: input.changed_files_count,
1321 changed_files: input.changed_files,
1322 base_ref: input.base_ref,
1323 base_description: input.base_description,
1324 head_sha,
1325 output: opts.output,
1326 performance: opts.performance,
1327 check: check_result,
1328 dupes: dupes_result,
1329 health: health_result,
1330 elapsed: input.start.elapsed(),
1331 review_deltas: brief.review_deltas,
1332 weakening_signals: brief.weakening_signals,
1333 routing: brief.routing,
1334 decision_surface: brief.decision_surface,
1335 graph_snapshot_hash: brief.graph_snapshot_hash,
1336 change_anchors: brief.change_anchors,
1337 }))
1338}
1339
1340fn drop_check_shared_parse(check_result: &mut Option<CheckResult>) {
1341 if let Some(check) = check_result {
1342 check.shared_parse = None;
1343 }
1344}
1345
1346fn compute_audit_brief_data(input: AuditBriefDataInput<'_>) -> AuditBriefData {
1347 if !input.opts.brief {
1348 return AuditBriefData::default();
1349 }
1350
1351 let (review_deltas, weakening_signals, routing) = compute_brief_e3_data(
1353 input.opts,
1354 input.check,
1355 input.base_snapshot,
1356 input.changed_files,
1357 input.base_ref,
1358 );
1359
1360 let decision_surface = Some(compute_decision_surface(
1362 input.opts,
1363 input.check,
1364 review_deltas.as_ref(),
1365 routing.as_ref(),
1366 ));
1367
1368 let change_anchors = compute_change_anchors(input.opts.root, input.base_ref);
1370
1371 let graph_snapshot_hash = Some(compute_graph_snapshot_hash(
1373 input.check,
1374 input.dupes,
1375 input.health,
1376 input.base_ref,
1377 input.head_sha,
1378 &change_anchors,
1379 ));
1380
1381 AuditBriefData {
1382 review_deltas,
1383 weakening_signals,
1384 routing,
1385 decision_surface,
1386 graph_snapshot_hash,
1387 change_anchors,
1388 }
1389}
1390
1391fn compute_graph_snapshot_hash(
1401 check: Option<&CheckResult>,
1402 dupes: Option<&DupesResult>,
1403 health: Option<&HealthResult>,
1404 base_ref: &str,
1405 head_sha: Option<&str>,
1406 change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
1407) -> String {
1408 let public_api = check
1412 .and_then(|c| c.public_api_keys.clone())
1413 .unwrap_or_default();
1414 let snapshot = snapshot_from_results(check, dupes, health, public_api);
1415 let mut bytes: Vec<u8> = Vec::new();
1416 for set in [
1418 &snapshot.dead_code,
1419 &snapshot.health,
1420 &snapshot.dupes,
1421 &snapshot.boundary_edges,
1422 &snapshot.cycles,
1423 &snapshot.public_api,
1424 ] {
1425 for key in sorted_keys(set) {
1426 bytes.extend_from_slice(key.as_bytes());
1427 bytes.push(0);
1428 }
1429 bytes.push(1);
1430 }
1431 let mut anchor_ids: Vec<&str> = change_anchors
1436 .iter()
1437 .map(|a| a.change_anchor.as_str())
1438 .collect();
1439 anchor_ids.sort_unstable();
1440 for id in anchor_ids {
1441 bytes.extend_from_slice(id.as_bytes());
1442 bytes.push(0);
1443 }
1444 bytes.push(1);
1445 bytes.extend_from_slice(base_ref.as_bytes());
1446 bytes.push(0);
1447 bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
1448 format!("graph:{:016x}", xxh3_64(&bytes))
1449}
1450
1451fn compute_change_anchors(
1459 root: &std::path::Path,
1460 base_ref: &str,
1461) -> Vec<crate::audit_walkthrough::ChangeAnchor> {
1462 if let Some(raw) = crate::report::ci::diff_filter::shared_diff_raw() {
1463 return crate::audit_walkthrough::parse_change_anchors(raw);
1464 }
1465 fallow_engine::changed_files::try_get_changed_diff(root, base_ref)
1466 .map(|diff| crate::audit_walkthrough::parse_change_anchors(&diff))
1467 .unwrap_or_default()
1468}
1469
1470fn compute_decision_surface(
1476 opts: &AuditOptions<'_>,
1477 check: Option<&CheckResult>,
1478 review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
1479 routing: Option<&routing::RoutingFacts>,
1480) -> crate::audit_decision_surface::DecisionSurface {
1481 use crate::audit_decision_surface::{
1482 CoordinationAnchor, DecisionInputs, extract_decision_surface,
1483 };
1484
1485 let (Some(check), Some(deltas)) = (check, review_deltas) else {
1486 return crate::audit_decision_surface::DecisionSurface::default();
1487 };
1488 let root = &check.config.root;
1489
1490 let boundary_anchors = decision_boundary_anchors(check, deltas, root);
1491
1492 let closure = check.impact_closure.as_ref();
1496 let mut coordination: Vec<CoordinationAnchor> = closure
1497 .map(|c| aggregate_coordination_gaps(&c.coordination_gap))
1498 .unwrap_or_default();
1499 let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
1500
1501 let empty_routing = routing::RoutingFacts::default();
1502 let routing = routing.unwrap_or(&empty_routing);
1503
1504 let root_owned = root.clone();
1508 let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
1509
1510 for anchor in &mut coordination {
1515 anchor.line = resolve_export_line(
1516 check.export_lines.as_ref(),
1517 &anchor.changed_file,
1518 &anchor.consumed_symbols,
1519 );
1520 }
1521 let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
1522 let mut parts = key.splitn(2, "::");
1523 let path = parts.next().unwrap_or_default();
1524 let name = parts.next().unwrap_or_default();
1525 resolve_export_line(check.export_lines.as_ref(), path, &[name.to_string()])
1526 });
1527
1528 let rename_old_path = |rel: &str| -> Option<String> {
1532 crate::report::ci::diff_filter::shared_diff_index()
1533 .and_then(|idx| idx.old_path_for_root_relative(rel))
1534 .map(std::borrow::Cow::into_owned)
1535 };
1536
1537 let internal_consumers_map = check.internal_consumers.as_ref();
1540 let internal_consumers = |rel: &str| -> u64 {
1541 internal_consumers_map
1542 .and_then(|map| map.get(rel))
1543 .copied()
1544 .unwrap_or(0)
1545 };
1546
1547 extract_decision_surface(&DecisionInputs {
1548 deltas,
1549 boundary_anchors: &boundary_anchors,
1550 coordination: &coordination,
1551 public_api_anchor_line,
1552 affected_not_shown,
1553 routing,
1554 head_source: &head_source,
1555 rename_old_path: &rename_old_path,
1556 internal_consumers: &internal_consumers,
1557 cap: opts.max_decisions,
1558 })
1559}
1560
1561fn decision_boundary_anchors(
1562 check: &CheckResult,
1563 deltas: &crate::audit_brief::ReviewDeltas,
1564 root: &std::path::Path,
1565) -> Vec<crate::audit_decision_surface::BoundaryAnchor> {
1566 use crate::audit_decision_surface::BoundaryAnchor;
1567
1568 let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
1569 let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
1570 for finding in &check.results.boundary_violations {
1571 let key = review_deltas::boundary_edge_key(finding);
1572 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
1573 continue;
1574 }
1575 boundary_anchors.push(BoundaryAnchor {
1576 zone_pair_key: key,
1577 from_file: keys::relative_key_path(&finding.violation.from_path, root),
1578 from_zone: finding.violation.from_zone.clone(),
1579 to_zone: finding.violation.to_zone.clone(),
1580 line: finding.violation.line,
1581 });
1582 }
1583 boundary_anchors
1584}
1585
1586fn resolve_export_line(
1587 export_lines: Option<&FxHashMap<String, Vec<(String, u32)>>>,
1588 rel: &str,
1589 symbols: &[String],
1590) -> u32 {
1591 let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
1592 return 0;
1593 };
1594 exports
1595 .iter()
1596 .find(|(name, _)| symbols.iter().any(|s| name == s))
1597 .or_else(|| exports.first())
1598 .map_or(0, |(_, line)| *line)
1599}
1600
1601fn aggregate_coordination_gaps(
1606 gaps: &[fallow_engine::module_graph::CoordinationGapPaths],
1607) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
1608 use crate::audit_decision_surface::CoordinationAnchor;
1609 let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
1610 for gap in gaps {
1611 let entry = by_file
1612 .entry(gap.changed_file.clone())
1613 .or_insert_with(|| (0, FxHashSet::default()));
1614 entry.0 += 1;
1615 for symbol in &gap.consumed_symbols {
1616 entry.1.insert(symbol.clone());
1617 }
1618 }
1619 let mut anchors: Vec<CoordinationAnchor> = by_file
1620 .into_iter()
1621 .map(|(changed_file, (consumer_count, symbols))| {
1622 let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
1623 consumed_symbols.sort_unstable();
1624 CoordinationAnchor {
1625 changed_file,
1626 consumed_symbols,
1627 consumer_count,
1628 line: 0,
1629 }
1630 })
1631 .collect();
1632 anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
1633 anchors
1634}
1635
1636fn compute_brief_e3_data(
1641 opts: &AuditOptions<'_>,
1642 check: Option<&CheckResult>,
1643 base_snapshot: Option<&AuditKeySnapshot>,
1644 changed_files: &FxHashSet<PathBuf>,
1645 base_ref: &str,
1646) -> (
1647 Option<crate::audit_brief::ReviewDeltas>,
1648 Vec<weakening::WeakeningSignal>,
1649 Option<routing::RoutingFacts>,
1650) {
1651 let deltas = check.map(|check| {
1652 let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
1653 let head_cycles =
1654 review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
1655 let head_public_api = check.public_api_keys.clone().unwrap_or_default();
1656 let empty = FxHashSet::default();
1657 let (base_boundary, base_cycles, base_public_api) = base_snapshot
1658 .map_or((&empty, &empty, &empty), |b| {
1659 (&b.boundary_edges, &b.cycles, &b.public_api)
1660 });
1661 crate::audit_brief::build_review_deltas(
1662 &head_boundary,
1663 base_boundary,
1664 &head_cycles,
1665 base_cycles,
1666 &head_public_api,
1667 base_public_api,
1668 )
1669 });
1670
1671 let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
1672
1673 let routing =
1674 check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
1675
1676 (deltas, weakening_signals, routing)
1677}
1678
1679fn compute_weakening_signals(
1684 root: &Path,
1685 base_ref: &str,
1686 changed_files: &FxHashSet<PathBuf>,
1687) -> Vec<weakening::WeakeningSignal> {
1688 let Some(git_root) = git_toplevel(root) else {
1689 return Vec::new();
1690 };
1691 let Some(mut reader) = BaseFileReader::spawn(root) else {
1692 return Vec::new();
1693 };
1694
1695 let mut signals = Vec::new();
1696 let mut files: Vec<&PathBuf> = changed_files.iter().collect();
1698 files.sort();
1699
1700 for abs in files {
1701 let Ok(relative) = abs.strip_prefix(&git_root) else {
1702 continue;
1703 };
1704 let rel_str = relative.to_string_lossy().replace('\\', "/");
1705 let head = std::fs::read_to_string(abs).unwrap_or_default();
1706 let base = reader.read(base_ref, relative).unwrap_or_default();
1707 signals.extend(weakening_signals_for_file(&rel_str, &base, &head));
1711 }
1712 signals
1713}
1714
1715fn weakening_signals_for_file(
1716 rel_str: &str,
1717 base: &str,
1718 head: &str,
1719) -> Vec<weakening::WeakeningSignal> {
1720 use weakening::WeakeningKind;
1721
1722 let mut signals = Vec::new();
1723 if weakening::is_test_file(rel_str) {
1724 extend_weakening_signals(
1725 &mut signals,
1726 WeakeningKind::TestWeakened,
1727 rel_str,
1728 weakening::detect_test_weakening(base, head)
1729 .into_iter()
1730 .map(|token| format!("{token} added")),
1731 );
1732 extend_weakening_signals(
1733 &mut signals,
1734 WeakeningKind::TestWeakened,
1735 rel_str,
1736 weakening::detect_removed_tests(base, head),
1737 );
1738 }
1739 extend_weakening_signals(
1740 &mut signals,
1741 WeakeningKind::SuppressionAdded,
1742 rel_str,
1743 weakening::detect_added_suppressions(base, head),
1744 );
1745 extend_weakening_signals(
1746 &mut signals,
1747 WeakeningKind::ThresholdLowered,
1748 rel_str,
1749 weakening::detect_lowered_thresholds(base, head),
1750 );
1751 if weakening::is_ci_file(rel_str) {
1752 extend_weakening_signals(
1753 &mut signals,
1754 WeakeningKind::SecurityCheckRemoved,
1755 rel_str,
1756 weakening::detect_removed_security_steps(base, head),
1757 );
1758 }
1759 signals
1760}
1761
1762fn extend_weakening_signals(
1763 signals: &mut Vec<weakening::WeakeningSignal>,
1764 kind: weakening::WeakeningKind,
1765 file: &str,
1766 evidences: impl IntoIterator<Item = String>,
1767) {
1768 signals.extend(
1769 evidences
1770 .into_iter()
1771 .map(|evidence| weakening::WeakeningSignal {
1772 kind,
1773 file: file.to_owned(),
1774 evidence,
1775 }),
1776 );
1777}
1778
1779fn compute_audit_outcome(
1782 gate: AuditGate,
1783 check: Option<&CheckResult>,
1784 dupes: Option<&DupesResult>,
1785 health: Option<&HealthResult>,
1786 base: Option<&AuditKeySnapshot>,
1787) -> (AuditAttribution, AuditVerdict, AuditSummary) {
1788 let attribution = compute_audit_attribution(check, dupes, health, base, gate);
1789 let verdict = compute_audit_verdict(gate, check, dupes, health, base);
1790 let summary = build_summary(check, dupes, health);
1791 crate::telemetry::note_final_result_count(
1792 summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1793 );
1794 (attribution, verdict, summary)
1795}
1796
1797#[derive(Clone, Copy)]
1804struct CurrentAnalysisRefs<'a> {
1805 check: Option<&'a CheckResult>,
1806 dupes: Option<&'a DupesResult>,
1807 health: Option<&'a HealthResult>,
1808}
1809
1810fn resolve_base_snapshot(
1811 opts: &AuditOptions<'_>,
1812 cached_base_snapshot: Option<AuditKeySnapshot>,
1813 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1814 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
1815 current: CurrentAnalysisRefs<'_>,
1816) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
1817 if !matches!(opts.gate, AuditGate::NewOnly) {
1818 return Ok((None, false));
1819 }
1820 if let Some(snapshot) = cached_base_snapshot {
1821 return Ok((Some(snapshot), false));
1822 }
1823 if let Some(base_res) = base_res {
1824 let snapshot = base_res?;
1825 if let Some(key) = base_cache_key {
1826 save_cached_base_snapshot(opts, key, &snapshot);
1827 }
1828 return Ok((Some(snapshot), false));
1829 }
1830 let CurrentAnalysisRefs {
1831 check,
1832 dupes,
1833 health,
1834 } = current;
1835 Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
1836}
1837
1838fn compute_audit_verdict(
1841 gate: AuditGate,
1842 check: Option<&CheckResult>,
1843 dupes: Option<&DupesResult>,
1844 health: Option<&HealthResult>,
1845 base: Option<&AuditKeySnapshot>,
1846) -> AuditVerdict {
1847 if matches!(gate, AuditGate::NewOnly) {
1848 compute_introduced_verdict(check, dupes, health, base)
1849 } else {
1850 compute_verdict(check, dupes, health)
1851 }
1852}
1853
1854fn build_audit_result(parts: AuditResultParts) -> AuditResult {
1855 AuditResult {
1856 verdict: parts.verdict,
1857 summary: parts.summary,
1858 attribution: parts.attribution,
1859 base_snapshot: parts.base_snapshot,
1860 base_snapshot_skipped: parts.base_snapshot_skipped,
1861 changed_files_count: parts.changed_files_count,
1862 changed_files: parts.changed_files.into_iter().collect(),
1863 base_ref: parts.base_ref,
1864 base_description: parts.base_description,
1865 head_sha: parts.head_sha,
1866 output: parts.output,
1867 performance: parts.performance,
1868 check: parts.check,
1869 dupes: parts.dupes,
1870 health: parts.health,
1871 elapsed: parts.elapsed,
1872 review_deltas: parts.review_deltas,
1873 weakening_signals: parts.weakening_signals,
1874 routing: parts.routing,
1875 decision_surface: parts.decision_surface,
1876 graph_snapshot_hash: parts.graph_snapshot_hash,
1877 change_anchors: parts.change_anchors,
1878 }
1879}
1880
1881fn empty_audit_result(
1883 base_ref: String,
1884 base_description: Option<String>,
1885 opts: &AuditOptions<'_>,
1886 elapsed: Duration,
1887) -> AuditResult {
1888 crate::telemetry::note_final_result_count(0);
1889
1890 let head_sha = get_head_sha(opts.root);
1891 let graph_snapshot_hash = if opts.brief {
1895 Some(compute_graph_snapshot_hash(
1897 None,
1898 None,
1899 None,
1900 &base_ref,
1901 head_sha.as_deref(),
1902 &[],
1903 ))
1904 } else {
1905 None
1906 };
1907
1908 AuditResult {
1909 verdict: AuditVerdict::Pass,
1910 summary: AuditSummary {
1911 dead_code_issues: 0,
1912 dead_code_has_errors: false,
1913 complexity_findings: 0,
1914 max_cyclomatic: None,
1915 duplication_clone_groups: 0,
1916 },
1917 attribution: AuditAttribution {
1918 gate: opts.gate,
1919 ..AuditAttribution::default()
1920 },
1921 base_snapshot: None,
1922 base_snapshot_skipped: false,
1923 changed_files_count: 0,
1924 changed_files: Vec::new(),
1925 base_ref,
1926 base_description,
1927 head_sha,
1928 output: opts.output,
1929 performance: opts.performance,
1930 check: None,
1931 dupes: None,
1932 health: None,
1933 elapsed,
1934 review_deltas: None,
1935 weakening_signals: Vec::new(),
1936 routing: None,
1937 decision_surface: None,
1938 graph_snapshot_hash,
1939 change_anchors: Vec::new(),
1940 }
1941}
1942
1943fn run_audit_check<'a>(
1945 opts: &'a AuditOptions<'a>,
1946 changed_since: Option<&'a str>,
1947 retain_modules_for_health: bool,
1948) -> Result<Option<CheckResult>, ExitCode> {
1949 let filters = IssueFilters::default();
1950 let retain_modules_for_health = retain_modules_for_health || opts.brief;
1955 let trace_opts = TraceOptions {
1956 trace_export: None,
1957 trace_file: None,
1958 trace_dependency: None,
1959 impact_closure: None,
1960 performance: opts.performance,
1961 };
1962 match crate::check::execute_check(&CheckOptions {
1963 root: opts.root,
1964 config_path: opts.config_path,
1965 output: opts.output,
1966 no_cache: opts.no_cache,
1967 threads: opts.threads,
1968 quiet: opts.quiet,
1969 allow_remote_extends: opts.allow_remote_extends,
1970 fail_on_issues: false,
1971 filters: &filters,
1972 changed_since,
1973 diff_index: None,
1974 use_shared_diff_index: true,
1975 baseline: opts.dead_code_baseline,
1976 save_baseline: None,
1977 sarif_file: None,
1978 production: opts.production_dead_code.unwrap_or(opts.production),
1979 production_override: opts.production_dead_code,
1980 workspace: opts.workspace,
1981 changed_workspaces: opts.changed_workspaces,
1982 group_by: opts.group_by,
1983 include_dupes: false,
1984 trace_opts: &trace_opts,
1985 explain: opts.explain,
1986 top: None,
1987 file: &[],
1988 include_entry_exports: opts.include_entry_exports,
1989 summary: false,
1990 regression_opts: crate::regression::RegressionOpts {
1991 fail_on_regression: false,
1992 tolerance: crate::regression::Tolerance::Absolute(0),
1993 regression_baseline_file: None,
1994 save_target: crate::regression::SaveRegressionTarget::None,
1995 scoped: true,
1996 quiet: opts.quiet,
1997 output: opts.output,
1998 },
1999 retain_modules_for_health,
2000 defer_performance: false,
2001 }) {
2002 Ok(r) => Ok(Some(r)),
2003 Err(code) => Err(code),
2004 }
2005}
2006
2007fn run_audit_dupes<'a>(
2013 opts: &'a AuditOptions<'a>,
2014 changed_since: Option<&'a str>,
2015 changed_files: Option<&'a FxHashSet<PathBuf>>,
2016 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2017) -> Result<Option<DupesResult>, ExitCode> {
2018 let dupes_cfg = match crate::load_config_for_analysis(
2019 opts.root,
2020 opts.config_path,
2021 crate::ConfigLoadOptions {
2022 output: opts.output,
2023 no_cache: opts.no_cache,
2024 threads: opts.threads,
2025 production_override: opts
2026 .production_dupes
2027 .or_else(|| opts.production.then_some(true)),
2028 quiet: opts.quiet,
2029 allow_remote_extends: opts.allow_remote_extends,
2030 },
2031 fallow_config::ProductionAnalysis::Dupes,
2032 ) {
2033 Ok(c) => c.duplicates,
2034 Err(code) => return Err(code),
2035 };
2036 let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
2037 let dupes_run = if let Some(files) = pre_discovered {
2038 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2039 } else {
2040 crate::dupes::execute_dupes(&dupes_opts)
2041 };
2042 match dupes_run {
2043 Ok(r) => Ok(Some(r)),
2044 Err(code) => Err(code),
2045 }
2046}
2047
2048fn build_audit_dupes_options<'a>(
2050 opts: &'a AuditOptions<'a>,
2051 changed_since: Option<&'a str>,
2052 changed_files: Option<&'a FxHashSet<PathBuf>>,
2053 dupes_cfg: &fallow_config::DuplicatesConfig,
2054) -> DupesOptions<'a> {
2055 DupesOptions {
2056 root: opts.root,
2057 config_path: opts.config_path,
2058 output: opts.output,
2059 no_cache: opts.no_cache,
2060 threads: opts.threads,
2061 quiet: opts.quiet,
2062 allow_remote_extends: opts.allow_remote_extends,
2063 mode: Some(DupesMode::from(dupes_cfg.mode)),
2064 min_tokens: Some(dupes_cfg.min_tokens),
2065 min_lines: Some(dupes_cfg.min_lines),
2066 min_occurrences: Some(dupes_cfg.min_occurrences),
2067 threshold: Some(dupes_cfg.threshold),
2068 skip_local: dupes_cfg.skip_local,
2069 cross_language: dupes_cfg.cross_language,
2070 ignore_imports: Some(dupes_cfg.ignore_imports),
2071 top: None,
2072 baseline_path: opts.dupes_baseline,
2073 save_baseline_path: None,
2074 production: opts.production_dupes.unwrap_or(opts.production),
2075 production_override: opts.production_dupes,
2076 trace: None,
2077 changed_since,
2078 diff_index: None,
2079 use_shared_diff_index: true,
2080 changed_files,
2081 workspace: opts.workspace,
2082 changed_workspaces: opts.changed_workspaces,
2083 explain: opts.explain,
2084 explain_skipped: opts.explain_skipped,
2085 summary: false,
2086 group_by: opts.group_by,
2087 performance: false,
2088 }
2089}
2090
2091fn run_audit_health<'a>(
2093 opts: &'a AuditOptions<'a>,
2094 changed_since: Option<&'a str>,
2095 shared_parse: Option<fallow_engine::health::HealthSharedParseData>,
2096) -> Result<Option<HealthResult>, ExitCode> {
2097 let runtime_coverage = match opts.runtime_coverage {
2098 Some(path) => match crate::health::coverage::prepare_options(
2099 path,
2100 opts.min_invocations_hot,
2101 None,
2102 None,
2103 opts.output,
2104 ) {
2105 Ok(options) => Some(options),
2106 Err(code) => return Err(code),
2107 },
2108 None => None,
2109 };
2110
2111 let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
2112 let health_run = if let Some(shared) = shared_parse {
2113 crate::health::execute_health_with_shared_parse(&health_opts, shared)
2114 } else {
2115 crate::health::execute_health(&health_opts)
2116 };
2117 match health_run {
2118 Ok(r) => Ok(Some(r)),
2119 Err(code) => Err(code),
2120 }
2121}
2122
2123fn build_audit_health_options<'a>(
2126 opts: &'a AuditOptions<'a>,
2127 changed_since: Option<&'a str>,
2128 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
2129) -> HealthOptions<'a> {
2130 HealthOptions {
2131 root: opts.root,
2132 config_path: opts.config_path,
2133 output: opts.output,
2134 no_cache: opts.no_cache,
2135 threads: opts.threads,
2136 quiet: opts.quiet,
2137 thresholds: fallow_engine::health::HealthThresholdOverrides {
2138 max_cyclomatic: None,
2139 max_cognitive: None,
2140 max_crap: opts.max_crap,
2141 },
2142 top: None,
2143 sort: fallow_engine::health::HealthSort::Cyclomatic,
2144 production: opts.production_health.unwrap_or(opts.production),
2145 production_override: opts.production_health,
2146 allow_remote_extends: opts.allow_remote_extends,
2147 changed_since,
2148 diff_index: None,
2149 use_shared_diff_index: true,
2150 workspace: opts.workspace,
2151 changed_workspaces: opts.changed_workspaces,
2152 baseline: opts.health_baseline,
2153 save_baseline: None,
2154 complexity: true,
2155 file_scores: false,
2156 coverage_gaps: false,
2157 config_activates_coverage_gaps: false,
2158 hotspots: false,
2159 ownership: false,
2160 ownership_emails: None,
2161 targets: false,
2162 css: opts.css,
2167 css_deep: opts.css_deep,
2168 force_full: false,
2169 score_only_output: false,
2170 enforce_coverage_gap_gate: false,
2171 effort: None,
2172 score: false,
2173 gates: fallow_engine::health::HealthGateOptions::default(),
2174 since: None,
2175 min_commits: None,
2176 explain: opts.explain,
2177 summary: false,
2178 save_snapshot: None,
2179 trend: false,
2180 coverage_inputs: fallow_engine::health::HealthCoverageInputs {
2181 coverage: opts.coverage,
2182 coverage_root: opts.coverage_root,
2183 },
2184 performance: opts.performance,
2185 runtime_coverage,
2186 churn_file: None,
2187 complexity_breakdown: false,
2188 group_by: opts.group_by.map(Into::into),
2189 }
2190}
2191
2192#[path = "audit_output.rs"]
2193mod output;
2194
2195pub use output::audit_json_header_input;
2196pub use output::{
2197 insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
2198 print_audit_findings, print_audit_result,
2199};
2200
2201pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
2207 if let Err(e) = fallow_engine::health::validate_coverage_root_absolute(opts.coverage_root) {
2208 return emit_error(&e, 2, opts.output);
2209 }
2210 let coverage_resolved = opts
2211 .coverage
2212 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2213 let runtime_coverage_resolved = opts
2214 .runtime_coverage
2215 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2216 let resolved_opts = AuditOptions {
2217 coverage: coverage_resolved.as_deref(),
2218 runtime_coverage: runtime_coverage_resolved.as_deref(),
2219 ..*opts
2220 };
2221 match execute_audit(&resolved_opts) {
2222 Ok(result) => {
2223 record_audit_impact(opts, gate_marker, &result);
2224 print_audit_command_result(opts, &result)
2225 }
2226 Err(code) => code,
2227 }
2228}
2229
2230fn record_audit_impact(opts: &AuditOptions<'_>, gate_marker: Option<&str>, result: &AuditResult) {
2231 let mut findings = result
2232 .check
2233 .as_ref()
2234 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
2235 .unwrap_or_default();
2236 if let Some(health) = result.health.as_ref() {
2237 findings.extend(crate::impact::collect_complexity_findings(&health.report));
2238 }
2239 let clones = result
2240 .dupes
2241 .as_ref()
2242 .map(|d| crate::impact::collect_clone_findings(&d.report))
2243 .unwrap_or_default();
2244 let empty_supps: Vec<fallow_types::results::ActiveSuppression> = Vec::new();
2245 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
2246 c.results.active_suppressions.as_slice()
2247 });
2248 let attribution = crate::impact::AttributionInput {
2249 root: opts.root,
2250 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
2251 findings,
2252 clones,
2253 suppressions,
2254 };
2255 crate::impact::record_audit_run(
2256 opts.root,
2257 &result.summary,
2258 &crate::impact::AuditRunRecord {
2259 verdict: result.verdict,
2260 gate: gate_marker.is_some(),
2261 git_sha: result.head_sha.as_deref(),
2262 version: env!("CARGO_PKG_VERSION"),
2263 timestamp: &crate::vital_signs::chrono_timestamp(),
2264 attribution: Some(&attribution),
2265 },
2266 );
2267}
2268
2269fn print_audit_command_result(opts: &AuditOptions<'_>, result: &AuditResult) -> ExitCode {
2270 if opts.walkthrough_guide {
2271 return crate::audit_brief::print_walkthrough_guide_result(result);
2272 }
2273 if opts.walkthrough {
2274 return crate::audit_brief::print_walkthrough_human_result(
2275 result,
2276 opts.root,
2277 opts.cache_dir,
2278 opts.mark_viewed,
2279 opts.show_cleared,
2280 opts.quiet,
2281 );
2282 }
2283 if let Some(path) = opts.walkthrough_file {
2284 return crate::audit_brief::print_walkthrough_file_result(result, path);
2285 }
2286 if opts.brief {
2287 return crate::audit_brief::print_brief_result(
2288 result,
2289 opts.quiet,
2290 opts.explain,
2291 opts.show_deprioritized,
2292 );
2293 }
2294 print_audit_result(result, opts.quiet, opts.explain)
2295}
2296
2297#[must_use]
2306pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
2307 let brief_opts = AuditOptions {
2309 brief: true,
2310 ..*opts
2311 };
2312 match execute_audit(&brief_opts) {
2313 Ok(result) => crate::audit_brief::print_decision_surface_result(&result, opts.quiet),
2314 Err(code) => code,
2315 }
2316}
2317
2318#[cfg(test)]
2319#[path = "audit_tests.rs"]
2320mod tests;