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 let root_pkg = fallow_config::PackageJson::load(&check.config.root.join("package.json")).ok();
585 review_deltas::public_export_keys_for(
586 graph,
587 &check.config,
588 root_pkg.as_ref(),
589 &check.workspaces,
590 root,
591 )
592}
593
594#[expect(
596 clippy::ref_option,
597 reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
598)]
599fn build_base_audit_options<'a>(
600 opts: &AuditOptions<'a>,
601 base_root: &'a Path,
602 current_config_path: &'a Option<PathBuf>,
603 base_cache_dir: &'a Path,
604) -> AuditOptions<'a> {
605 AuditOptions {
606 root: base_root,
607 config_path: current_config_path,
608 cache_dir: base_cache_dir,
609 output: opts.output,
610 no_cache: opts.no_cache,
611 threads: opts.threads,
612 quiet: true,
613 changed_since: None,
614 production: opts.production,
615 production_dead_code: opts.production_dead_code,
616 production_health: opts.production_health,
617 production_dupes: opts.production_dupes,
618 workspace: opts.workspace,
619 changed_workspaces: None,
620 explain: false,
621 explain_skipped: false,
622 performance: false,
623 group_by: opts.group_by,
624 dead_code_baseline: None,
625 health_baseline: None,
626 dupes_baseline: None,
627 max_crap: opts.max_crap,
628 coverage: opts.coverage,
629 coverage_root: opts.coverage_root,
630 gate: AuditGate::All,
631 include_entry_exports: opts.include_entry_exports,
632 css: opts.css,
635 css_deep: opts.css_deep,
636 runtime_coverage: None,
637 min_invocations_hot: opts.min_invocations_hot,
638 brief: false,
639 max_decisions: 4,
640 walkthrough_guide: false,
641 walkthrough: false,
642 mark_viewed: &[],
643 show_cleared: false,
644 walkthrough_file: None,
645 show_deprioritized: false,
646 }
647}
648
649fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
650 let Some(git_root) = git_toplevel(current_root) else {
651 return base_worktree_root.to_path_buf();
652 };
653 let current_root =
654 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
655 match current_root.strip_prefix(&git_root) {
656 Ok(relative) => base_worktree_root.join(relative),
657 Err(err) => {
658 tracing::warn!(
659 current_root = %current_root.display(),
660 git_root = %git_root.display(),
661 error = %err,
662 "Could not remap audit base root into the base worktree; falling back to worktree root"
663 );
664 base_worktree_root.to_path_buf()
665 }
666 }
667}
668
669fn current_keys_as_base_keys(
670 check: Option<&CheckResult>,
671 dupes: Option<&DupesResult>,
672 health: Option<&HealthResult>,
673) -> AuditKeySnapshot {
674 let public_api = check
679 .and_then(|r| r.public_api_keys.clone())
680 .unwrap_or_default();
681 snapshot_from_results(check, dupes, health, public_api)
682}
683
684fn can_reuse_current_as_base(
685 opts: &AuditOptions<'_>,
686 base_ref: &str,
687 changed_files: &FxHashSet<PathBuf>,
688) -> bool {
689 let Some(git_root) = git_toplevel(opts.root) else {
690 return false;
691 };
692 let cache_dir = opts.cache_dir.to_path_buf();
693 let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
694 let mut reader: Option<BaseFileReader> = None;
697 for path in changed_files {
698 if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
699 continue;
700 }
701 if !is_analysis_input(path) {
702 if is_non_behavioral_doc(path) {
703 continue;
704 }
705 return false;
706 }
707 let Ok(current) = std::fs::read_to_string(path) else {
708 return false;
709 };
710 let Ok(relative) = path.strip_prefix(&git_root) else {
711 return false;
712 };
713 let reader = match reader.as_mut() {
714 Some(reader) => reader,
715 None => {
716 let Some(spawned) = BaseFileReader::spawn(opts.root) else {
717 return false;
718 };
719 reader.insert(spawned)
720 }
721 };
722 let Some(base) = reader.read(base_ref, relative) else {
723 return false;
724 };
725 if current == base {
726 continue;
727 }
728 if !js_ts_tokens_equivalent(path, ¤t, &base) {
729 return false;
730 }
731 }
732 true
733}
734
735struct BaseFileReader {
748 child: Option<crate::signal::ScopedChild>,
752 stdin: Option<std::process::ChildStdin>,
755 stdout: std::io::BufReader<std::process::ChildStdout>,
756}
757
758impl BaseFileReader {
759 fn spawn(root: &Path) -> Option<Self> {
765 let mut command = Command::new("git");
766 command
767 .args(["cat-file", "--batch"])
768 .current_dir(root)
769 .stdin(std::process::Stdio::piped())
770 .stdout(std::process::Stdio::piped())
771 .stderr(std::process::Stdio::null());
772 clear_ambient_git_env(&mut command);
773 let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
774 let stdin = child.take_stdin()?;
775 let stdout = child.take_stdout()?;
776 Some(Self {
777 child: Some(child),
778 stdin: Some(stdin),
779 stdout: std::io::BufReader::new(stdout),
780 })
781 }
782
783 fn read(&mut self, base_ref: &str, relative: &Path) -> Option<String> {
790 use std::io::{BufRead, Read};
791
792 let relative = relative.to_string_lossy().replace('\\', "/");
793 if relative.contains('\n') {
796 return None;
797 }
798
799 let stdin = self.stdin.as_mut()?;
800 writeln!(stdin, "{base_ref}:{relative}").ok()?;
801 stdin.flush().ok()?;
802
803 let mut header = String::new();
804 if self.stdout.read_line(&mut header).ok()? == 0 {
805 return None;
806 }
807 if header.trim_end().ends_with(" missing") {
809 return None;
810 }
811 let size: usize = header.trim_end().rsplit(' ').next()?.parse().ok()?;
813 let mut buf = vec![0u8; size];
814 self.stdout.read_exact(&mut buf).ok()?;
815 let mut newline = [0u8; 1];
818 self.stdout.read_exact(&mut newline).ok()?;
819
820 Some(String::from_utf8_lossy(&buf).into_owned())
821 }
822}
823
824impl Drop for BaseFileReader {
825 fn drop(&mut self) {
826 self.stdin.take();
831 if let Some(child) = self.child.take() {
832 let _ = child.wait();
833 }
834 }
835}
836
837fn is_fallow_cache_artifact(
838 path: &Path,
839 cache_dir: &Path,
840 canonical_cache_dir: Option<&Path>,
841) -> bool {
842 path.starts_with(cache_dir)
843 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
844}
845
846fn remap_cache_dir_for_base_worktree(
847 current_root: &Path,
848 base_worktree_root: &Path,
849 cache_dir: &Path,
850) -> PathBuf {
851 if cache_dir.is_absolute()
852 && let Ok(relative) = cache_dir.strip_prefix(current_root)
853 {
854 return base_worktree_root.join(relative);
855 }
856 cache_dir.to_path_buf()
857}
858
859fn is_analysis_input(path: &Path) -> bool {
860 matches!(
861 path.extension().and_then(|ext| ext.to_str()),
862 Some(
863 "js" | "jsx"
864 | "ts"
865 | "tsx"
866 | "mjs"
867 | "mts"
868 | "cjs"
869 | "cts"
870 | "vue"
871 | "svelte"
872 | "astro"
873 | "mdx"
874 | "css"
875 | "scss"
876 )
877 )
878}
879
880fn is_non_behavioral_doc(path: &Path) -> bool {
881 matches!(
882 path.extension().and_then(|ext| ext.to_str()),
883 Some("md" | "markdown" | "txt" | "rst" | "adoc")
884 )
885}
886
887fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
888 if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
889 return false;
890 }
891 if !matches!(
892 path.extension().and_then(|ext| ext.to_str()),
893 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
894 ) {
895 return false;
896 }
897 fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
898}
899
900fn remap_focus_files(
901 files: &FxHashSet<PathBuf>,
902 from_root: &Path,
903 to_root: &Path,
904) -> Option<FxHashSet<PathBuf>> {
905 let mut remapped = FxHashSet::default();
906 for file in files {
907 if let Ok(relative) = file.strip_prefix(from_root) {
908 remapped.insert(to_root.join(relative));
909 }
910 }
911 if remapped.is_empty() {
912 return None;
913 }
914 Some(remapped)
915}
916
917#[cfg(test)]
918use std::time::SystemTime;
919
920#[cfg(test)]
921use crate::base_worktree::{
922 ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
923 is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
924 materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
925 remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
926 sweep_orphan_audit_worktrees, touch_last_used,
927};
928
929pub use fallow_api::audit_keys as keys;
930
931#[path = "audit_review_deltas.rs"]
932pub mod review_deltas;
933
934#[path = "audit_weakening.rs"]
935pub mod weakening;
936
937#[path = "audit_routing.rs"]
938pub mod routing;
939
940use keys::{
941 dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
942 retain_introduced_dead_code, styling_finding_key, styling_keys,
943};
944
945struct HeadAnalyses {
946 check: Option<CheckResult>,
947 dupes: Option<DupesResult>,
948 health: Option<HealthResult>,
949}
950
951type HeadAndBaseResult = (
954 Result<HeadAnalyses, ExitCode>,
955 Option<Result<AuditKeySnapshot, ExitCode>>,
956);
957
958fn run_audit_head_and_base(
961 opts: &AuditOptions<'_>,
962 changed_since: Option<&str>,
963 changed_files: &FxHashSet<PathBuf>,
964 base_ref: &str,
965 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
966 run_base: bool,
967) -> HeadAndBaseResult {
968 if run_base {
969 let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
970 let (h, b) = rayon::join(
971 || run_audit_head_analyses(opts, changed_since, changed_files),
972 || compute_base_snapshot(opts, base_ref, changed_files, base_sha),
973 );
974 (h, Some(b))
975 } else {
976 (
977 run_audit_head_analyses(opts, changed_since, changed_files),
978 None,
979 )
980 }
981}
982
983struct AuditResultParts {
984 verdict: AuditVerdict,
985 summary: AuditSummary,
986 attribution: AuditAttribution,
987 base_snapshot: Option<AuditKeySnapshot>,
988 base_snapshot_skipped: bool,
989 changed_files_count: usize,
990 changed_files: FxHashSet<PathBuf>,
991 base_ref: String,
992 base_description: Option<String>,
993 head_sha: Option<String>,
994 output: OutputFormat,
995 performance: bool,
996 check: Option<CheckResult>,
997 dupes: Option<DupesResult>,
998 health: Option<HealthResult>,
999 elapsed: Duration,
1000 review_deltas: Option<crate::audit_brief::ReviewDeltas>,
1001 weakening_signals: Vec<weakening::WeakeningSignal>,
1002 routing: Option<routing::RoutingFacts>,
1003 decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
1004 graph_snapshot_hash: Option<String>,
1005 change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
1006}
1007
1008fn run_audit_head_analyses(
1015 opts: &AuditOptions<'_>,
1016 changed_since: Option<&str>,
1017 changed_files: &FxHashSet<PathBuf>,
1018) -> Result<HeadAnalyses, ExitCode> {
1019 let check_production = opts.production_dead_code.unwrap_or(opts.production);
1020 let health_production = opts.production_health.unwrap_or(opts.production);
1021 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
1022 let share_dead_code_parse_with_health = check_production == health_production;
1023 let share_dead_code_files_with_dupes =
1024 share_dead_code_parse_with_health && check_production == dupes_production;
1025
1026 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
1027 let dupes_files = if share_dead_code_files_with_dupes {
1028 check
1029 .as_ref()
1030 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
1031 } else {
1032 None
1033 };
1034 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
1035 if opts.brief
1040 && let Some(ref mut check) = check
1041 {
1042 check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
1043 check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
1044 check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
1045 check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
1046 check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
1047 check.internal_consumers =
1048 compute_brief_internal_consumers(opts.root, check, changed_files);
1049 }
1050 let shared_parse = if share_dead_code_parse_with_health {
1051 check.as_mut().and_then(|r| r.shared_parse.take())
1052 } else {
1053 None
1054 };
1055 let health = run_audit_health(opts, changed_since, shared_parse)?;
1056 Ok(HeadAnalyses {
1057 check,
1058 dupes,
1059 health,
1060 })
1061}
1062
1063fn compute_brief_impact_closure(
1071 root: &std::path::Path,
1072 check: &CheckResult,
1073 changed_files: &FxHashSet<PathBuf>,
1074) -> Option<fallow_engine::module_graph::ImpactClosurePaths> {
1075 let graph = check
1076 .shared_parse
1077 .as_ref()
1078 .and_then(|sp| sp.analysis_output.as_ref())
1079 .and_then(|out| out.graph.as_ref())?;
1080
1081 fallow_engine::module_graph::impact_closure_for_changed_paths(graph, root, changed_files)
1082}
1083
1084fn compute_brief_partition_order(
1092 root: &std::path::Path,
1093 check: &CheckResult,
1094 changed_files: &FxHashSet<PathBuf>,
1095) -> Option<fallow_engine::module_graph::PartitionOrderPaths> {
1096 let graph = check
1097 .shared_parse
1098 .as_ref()
1099 .and_then(|sp| sp.analysis_output.as_ref())
1100 .and_then(|out| out.graph.as_ref())?;
1101
1102 fallow_engine::module_graph::partition_order_for_changed_paths(graph, root, changed_files)
1103}
1104
1105fn compute_brief_export_lines(
1110 root: &std::path::Path,
1111 check: &CheckResult,
1112 changed_files: &FxHashSet<PathBuf>,
1113) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
1114 let graph = check
1115 .shared_parse
1116 .as_ref()
1117 .and_then(|sp| sp.analysis_output.as_ref())
1118 .and_then(|out| out.graph.as_ref())?;
1119
1120 fallow_engine::module_graph::export_lines_for_changed_paths(graph, root, changed_files)
1121}
1122
1123fn compute_brief_internal_consumers(
1132 root: &std::path::Path,
1133 check: &CheckResult,
1134 changed_files: &FxHashSet<PathBuf>,
1135) -> Option<FxHashMap<String, u64>> {
1136 let graph = check
1137 .shared_parse
1138 .as_ref()
1139 .and_then(|sp| sp.analysis_output.as_ref())
1140 .and_then(|out| out.graph.as_ref())?;
1141
1142 fallow_engine::module_graph::internal_consumers_for_changed_paths(graph, root, changed_files)
1143}
1144
1145fn compute_brief_focus_facts(
1155 root: &std::path::Path,
1156 check: &CheckResult,
1157 changed_files: &FxHashSet<PathBuf>,
1158) -> Option<Vec<fallow_engine::module_graph::FocusFileFactsPaths>> {
1159 let graph = check
1160 .shared_parse
1161 .as_ref()
1162 .and_then(|sp| sp.analysis_output.as_ref())
1163 .and_then(|out| out.graph.as_ref())?;
1164
1165 fallow_engine::module_graph::focus_facts_for_changed_paths(graph, root, changed_files)
1166}
1167
1168pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1170 let start = Instant::now();
1171
1172 let (base_ref, base_description) = resolve_base_ref(opts)?;
1173
1174 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1175 return Err(emit_error(
1176 &format!(
1177 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1178 ),
1179 2,
1180 opts.output,
1181 ));
1182 };
1183 let changed_files_count = changed_files.len();
1184
1185 if changed_files.is_empty() {
1186 return Ok(empty_audit_result(
1187 base_ref,
1188 base_description,
1189 opts,
1190 start.elapsed(),
1191 ));
1192 }
1193
1194 sweep_old_reusable_caches(
1198 opts.root,
1199 resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1200 opts.quiet,
1201 );
1202
1203 let changed_since = Some(base_ref.as_str());
1204
1205 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1206 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1207 let base_cache_key = if needs_real_base_snapshot {
1208 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1209 } else {
1210 None
1211 };
1212 let cached_base_snapshot = base_cache_key
1213 .as_ref()
1214 .and_then(|key| load_cached_base_snapshot(opts, key));
1215
1216 let (head_res, base_res) = run_audit_head_and_base(
1217 opts,
1218 changed_since,
1219 &changed_files,
1220 &base_ref,
1221 base_cache_key.as_ref(),
1222 needs_real_base_snapshot && cached_base_snapshot.is_none(),
1223 );
1224
1225 assemble_audit_result(AuditAssemblyInput {
1226 opts,
1227 head_res,
1228 base_res,
1229 cached_base_snapshot,
1230 base_cache_key,
1231 changed_files,
1232 changed_files_count,
1233 base_ref,
1234 base_description,
1235 start,
1236 })
1237}
1238
1239struct AuditAssemblyInput<'a> {
1241 opts: &'a AuditOptions<'a>,
1242 head_res: Result<HeadAnalyses, ExitCode>,
1243 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1244 cached_base_snapshot: Option<AuditKeySnapshot>,
1245 base_cache_key: Option<AuditBaseSnapshotCacheKey>,
1246 changed_files: FxHashSet<PathBuf>,
1247 changed_files_count: usize,
1248 base_ref: String,
1249 base_description: Option<String>,
1250 start: Instant,
1251}
1252
1253fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
1256 let opts = input.opts;
1257 let head = input.head_res?;
1258 let mut check_result = head.check;
1259 let dupes_result = head.dupes;
1260 let health_result = head.health;
1261
1262 let (base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
1263 opts,
1264 input.cached_base_snapshot,
1265 input.base_res,
1266 input.base_cache_key.as_ref(),
1267 CurrentAnalysisRefs {
1268 check: check_result.as_ref(),
1269 dupes: dupes_result.as_ref(),
1270 health: health_result.as_ref(),
1271 },
1272 )?;
1273 if let Some(ref mut check) = check_result {
1274 check.shared_parse = None;
1275 }
1276 let (attribution, verdict, summary) = compute_audit_outcome(
1277 opts.gate,
1278 check_result.as_ref(),
1279 dupes_result.as_ref(),
1280 health_result.as_ref(),
1281 base_snapshot.as_ref(),
1282 );
1283
1284 let (review_deltas, weakening_signals, routing) = if opts.brief {
1287 compute_brief_e3_data(
1288 opts,
1289 check_result.as_ref(),
1290 base_snapshot.as_ref(),
1291 &input.changed_files,
1292 &input.base_ref,
1293 )
1294 } else {
1295 (None, Vec::new(), None)
1296 };
1297
1298 let decision_surface = if opts.brief {
1302 Some(compute_decision_surface(
1303 opts,
1304 check_result.as_ref(),
1305 review_deltas.as_ref(),
1306 routing.as_ref(),
1307 ))
1308 } else {
1309 None
1310 };
1311
1312 let change_anchors = if opts.brief {
1317 compute_change_anchors(opts.root, &input.base_ref)
1318 } else {
1319 Vec::new()
1320 };
1321
1322 let head_sha = get_head_sha(opts.root);
1328 let graph_snapshot_hash = if opts.brief {
1329 Some(compute_graph_snapshot_hash(
1330 check_result.as_ref(),
1331 dupes_result.as_ref(),
1332 health_result.as_ref(),
1333 &input.base_ref,
1334 head_sha.as_deref(),
1335 &change_anchors,
1336 ))
1337 } else {
1338 None
1339 };
1340
1341 Ok(build_audit_result(AuditResultParts {
1342 verdict,
1343 summary,
1344 attribution,
1345 base_snapshot,
1346 base_snapshot_skipped,
1347 changed_files_count: input.changed_files_count,
1348 changed_files: input.changed_files,
1349 base_ref: input.base_ref,
1350 base_description: input.base_description,
1351 head_sha,
1352 output: opts.output,
1353 performance: opts.performance,
1354 check: check_result,
1355 dupes: dupes_result,
1356 health: health_result,
1357 elapsed: input.start.elapsed(),
1358 review_deltas,
1359 weakening_signals,
1360 routing,
1361 decision_surface,
1362 graph_snapshot_hash,
1363 change_anchors,
1364 }))
1365}
1366
1367fn compute_graph_snapshot_hash(
1377 check: Option<&CheckResult>,
1378 dupes: Option<&DupesResult>,
1379 health: Option<&HealthResult>,
1380 base_ref: &str,
1381 head_sha: Option<&str>,
1382 change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
1383) -> String {
1384 let public_api = check
1388 .and_then(|c| c.public_api_keys.clone())
1389 .unwrap_or_default();
1390 let snapshot = snapshot_from_results(check, dupes, health, public_api);
1391 let mut bytes: Vec<u8> = Vec::new();
1392 for set in [
1394 &snapshot.dead_code,
1395 &snapshot.health,
1396 &snapshot.dupes,
1397 &snapshot.boundary_edges,
1398 &snapshot.cycles,
1399 &snapshot.public_api,
1400 ] {
1401 for key in sorted_keys(set) {
1402 bytes.extend_from_slice(key.as_bytes());
1403 bytes.push(0);
1404 }
1405 bytes.push(1);
1406 }
1407 let mut anchor_ids: Vec<&str> = change_anchors
1412 .iter()
1413 .map(|a| a.change_anchor.as_str())
1414 .collect();
1415 anchor_ids.sort_unstable();
1416 for id in anchor_ids {
1417 bytes.extend_from_slice(id.as_bytes());
1418 bytes.push(0);
1419 }
1420 bytes.push(1);
1421 bytes.extend_from_slice(base_ref.as_bytes());
1422 bytes.push(0);
1423 bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
1424 format!("graph:{:016x}", xxh3_64(&bytes))
1425}
1426
1427fn compute_change_anchors(
1435 root: &std::path::Path,
1436 base_ref: &str,
1437) -> Vec<crate::audit_walkthrough::ChangeAnchor> {
1438 if let Some(raw) = crate::report::ci::diff_filter::shared_diff_raw() {
1439 return crate::audit_walkthrough::parse_change_anchors(raw);
1440 }
1441 fallow_engine::changed_files::try_get_changed_diff(root, base_ref)
1442 .map(|diff| crate::audit_walkthrough::parse_change_anchors(&diff))
1443 .unwrap_or_default()
1444}
1445
1446fn compute_decision_surface(
1452 opts: &AuditOptions<'_>,
1453 check: Option<&CheckResult>,
1454 review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
1455 routing: Option<&routing::RoutingFacts>,
1456) -> crate::audit_decision_surface::DecisionSurface {
1457 use crate::audit_decision_surface::{
1458 BoundaryAnchor, CoordinationAnchor, DecisionInputs, extract_decision_surface,
1459 };
1460
1461 let (Some(check), Some(deltas)) = (check, review_deltas) else {
1462 return crate::audit_decision_surface::DecisionSurface::default();
1463 };
1464 let root = &check.config.root;
1465
1466 let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
1470 let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
1471 for finding in &check.results.boundary_violations {
1472 let key = review_deltas::boundary_edge_key(finding);
1473 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
1474 continue;
1475 }
1476 boundary_anchors.push(BoundaryAnchor {
1477 zone_pair_key: key,
1478 from_file: keys::relative_key_path(&finding.violation.from_path, root),
1479 from_zone: finding.violation.from_zone.clone(),
1480 to_zone: finding.violation.to_zone.clone(),
1481 line: finding.violation.line,
1482 });
1483 }
1484
1485 let closure = check.impact_closure.as_ref();
1489 let mut coordination: Vec<CoordinationAnchor> = closure
1490 .map(|c| aggregate_coordination_gaps(&c.coordination_gap))
1491 .unwrap_or_default();
1492 let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
1493
1494 let empty_routing = routing::RoutingFacts::default();
1495 let routing = routing.unwrap_or(&empty_routing);
1496
1497 let root_owned = root.clone();
1501 let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
1502
1503 let export_lines = check.export_lines.as_ref();
1508 let resolve_line = |rel: &str, symbols: &[String]| -> u32 {
1509 let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
1510 return 0;
1511 };
1512 exports
1513 .iter()
1514 .find(|(name, _)| symbols.iter().any(|s| name == s))
1515 .or_else(|| exports.first())
1516 .map_or(0, |(_, line)| *line)
1517 };
1518 for anchor in &mut coordination {
1519 anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
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_line(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(rel))
1534 .map(str::to_string)
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 aggregate_coordination_gaps(
1566 gaps: &[fallow_engine::module_graph::CoordinationGapPaths],
1567) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
1568 use crate::audit_decision_surface::CoordinationAnchor;
1569 let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
1570 for gap in gaps {
1571 let entry = by_file
1572 .entry(gap.changed_file.clone())
1573 .or_insert_with(|| (0, FxHashSet::default()));
1574 entry.0 += 1;
1575 for symbol in &gap.consumed_symbols {
1576 entry.1.insert(symbol.clone());
1577 }
1578 }
1579 let mut anchors: Vec<CoordinationAnchor> = by_file
1580 .into_iter()
1581 .map(|(changed_file, (consumer_count, symbols))| {
1582 let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
1583 consumed_symbols.sort_unstable();
1584 CoordinationAnchor {
1585 changed_file,
1586 consumed_symbols,
1587 consumer_count,
1588 line: 0,
1589 }
1590 })
1591 .collect();
1592 anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
1593 anchors
1594}
1595
1596fn compute_brief_e3_data(
1601 opts: &AuditOptions<'_>,
1602 check: Option<&CheckResult>,
1603 base_snapshot: Option<&AuditKeySnapshot>,
1604 changed_files: &FxHashSet<PathBuf>,
1605 base_ref: &str,
1606) -> (
1607 Option<crate::audit_brief::ReviewDeltas>,
1608 Vec<weakening::WeakeningSignal>,
1609 Option<routing::RoutingFacts>,
1610) {
1611 let deltas = check.map(|check| {
1612 let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
1613 let head_cycles =
1614 review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
1615 let head_public_api = check.public_api_keys.clone().unwrap_or_default();
1616 let empty = FxHashSet::default();
1617 let (base_boundary, base_cycles, base_public_api) = base_snapshot
1618 .map_or((&empty, &empty, &empty), |b| {
1619 (&b.boundary_edges, &b.cycles, &b.public_api)
1620 });
1621 crate::audit_brief::build_review_deltas(
1622 &head_boundary,
1623 base_boundary,
1624 &head_cycles,
1625 base_cycles,
1626 &head_public_api,
1627 base_public_api,
1628 )
1629 });
1630
1631 let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
1632
1633 let routing =
1634 check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
1635
1636 (deltas, weakening_signals, routing)
1637}
1638
1639fn compute_weakening_signals(
1644 root: &Path,
1645 base_ref: &str,
1646 changed_files: &FxHashSet<PathBuf>,
1647) -> Vec<weakening::WeakeningSignal> {
1648 use weakening::{WeakeningKind, WeakeningSignal};
1649
1650 let Some(git_root) = git_toplevel(root) else {
1651 return Vec::new();
1652 };
1653 let Some(mut reader) = BaseFileReader::spawn(root) else {
1654 return Vec::new();
1655 };
1656
1657 let mut signals: Vec<WeakeningSignal> = Vec::new();
1658 let mut files: Vec<&PathBuf> = changed_files.iter().collect();
1660 files.sort();
1661
1662 for abs in files {
1663 let Ok(relative) = abs.strip_prefix(&git_root) else {
1664 continue;
1665 };
1666 let rel_str = relative.to_string_lossy().replace('\\', "/");
1667 let head = std::fs::read_to_string(abs).unwrap_or_default();
1668 let base = reader.read(base_ref, relative).unwrap_or_default();
1669 if weakening::is_test_file(&rel_str) {
1673 for token in weakening::detect_test_weakening(&base, &head) {
1674 signals.push(WeakeningSignal {
1675 kind: WeakeningKind::TestWeakened,
1676 file: rel_str.clone(),
1677 evidence: format!("{token} added"),
1678 });
1679 }
1680 for ev in weakening::detect_removed_tests(&base, &head) {
1681 signals.push(WeakeningSignal {
1682 kind: WeakeningKind::TestWeakened,
1683 file: rel_str.clone(),
1684 evidence: ev,
1685 });
1686 }
1687 }
1688 for ev in weakening::detect_added_suppressions(&base, &head) {
1689 signals.push(WeakeningSignal {
1690 kind: WeakeningKind::SuppressionAdded,
1691 file: rel_str.clone(),
1692 evidence: ev,
1693 });
1694 }
1695 for ev in weakening::detect_lowered_thresholds(&base, &head) {
1696 signals.push(WeakeningSignal {
1697 kind: WeakeningKind::ThresholdLowered,
1698 file: rel_str.clone(),
1699 evidence: ev,
1700 });
1701 }
1702 if weakening::is_ci_file(&rel_str) {
1703 for ev in weakening::detect_removed_security_steps(&base, &head) {
1704 signals.push(WeakeningSignal {
1705 kind: WeakeningKind::SecurityCheckRemoved,
1706 file: rel_str.clone(),
1707 evidence: ev,
1708 });
1709 }
1710 }
1711 }
1712 signals
1713}
1714
1715fn compute_audit_outcome(
1718 gate: AuditGate,
1719 check: Option<&CheckResult>,
1720 dupes: Option<&DupesResult>,
1721 health: Option<&HealthResult>,
1722 base: Option<&AuditKeySnapshot>,
1723) -> (AuditAttribution, AuditVerdict, AuditSummary) {
1724 let attribution = compute_audit_attribution(check, dupes, health, base, gate);
1725 let verdict = compute_audit_verdict(gate, check, dupes, health, base);
1726 let summary = build_summary(check, dupes, health);
1727 crate::telemetry::note_final_result_count(
1728 summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1729 );
1730 (attribution, verdict, summary)
1731}
1732
1733#[derive(Clone, Copy)]
1740struct CurrentAnalysisRefs<'a> {
1741 check: Option<&'a CheckResult>,
1742 dupes: Option<&'a DupesResult>,
1743 health: Option<&'a HealthResult>,
1744}
1745
1746fn resolve_base_snapshot(
1747 opts: &AuditOptions<'_>,
1748 cached_base_snapshot: Option<AuditKeySnapshot>,
1749 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1750 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
1751 current: CurrentAnalysisRefs<'_>,
1752) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
1753 if !matches!(opts.gate, AuditGate::NewOnly) {
1754 return Ok((None, false));
1755 }
1756 if let Some(snapshot) = cached_base_snapshot {
1757 return Ok((Some(snapshot), false));
1758 }
1759 if let Some(base_res) = base_res {
1760 let snapshot = base_res?;
1761 if let Some(key) = base_cache_key {
1762 save_cached_base_snapshot(opts, key, &snapshot);
1763 }
1764 return Ok((Some(snapshot), false));
1765 }
1766 let CurrentAnalysisRefs {
1767 check,
1768 dupes,
1769 health,
1770 } = current;
1771 Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
1772}
1773
1774fn compute_audit_verdict(
1777 gate: AuditGate,
1778 check: Option<&CheckResult>,
1779 dupes: Option<&DupesResult>,
1780 health: Option<&HealthResult>,
1781 base: Option<&AuditKeySnapshot>,
1782) -> AuditVerdict {
1783 if matches!(gate, AuditGate::NewOnly) {
1784 compute_introduced_verdict(check, dupes, health, base)
1785 } else {
1786 compute_verdict(check, dupes, health)
1787 }
1788}
1789
1790fn build_audit_result(parts: AuditResultParts) -> AuditResult {
1791 AuditResult {
1792 verdict: parts.verdict,
1793 summary: parts.summary,
1794 attribution: parts.attribution,
1795 base_snapshot: parts.base_snapshot,
1796 base_snapshot_skipped: parts.base_snapshot_skipped,
1797 changed_files_count: parts.changed_files_count,
1798 changed_files: parts.changed_files.into_iter().collect(),
1799 base_ref: parts.base_ref,
1800 base_description: parts.base_description,
1801 head_sha: parts.head_sha,
1802 output: parts.output,
1803 performance: parts.performance,
1804 check: parts.check,
1805 dupes: parts.dupes,
1806 health: parts.health,
1807 elapsed: parts.elapsed,
1808 review_deltas: parts.review_deltas,
1809 weakening_signals: parts.weakening_signals,
1810 routing: parts.routing,
1811 decision_surface: parts.decision_surface,
1812 graph_snapshot_hash: parts.graph_snapshot_hash,
1813 change_anchors: parts.change_anchors,
1814 }
1815}
1816
1817fn empty_audit_result(
1819 base_ref: String,
1820 base_description: Option<String>,
1821 opts: &AuditOptions<'_>,
1822 elapsed: Duration,
1823) -> AuditResult {
1824 crate::telemetry::note_final_result_count(0);
1825
1826 let head_sha = get_head_sha(opts.root);
1827 let graph_snapshot_hash = if opts.brief {
1831 Some(compute_graph_snapshot_hash(
1833 None,
1834 None,
1835 None,
1836 &base_ref,
1837 head_sha.as_deref(),
1838 &[],
1839 ))
1840 } else {
1841 None
1842 };
1843
1844 AuditResult {
1845 verdict: AuditVerdict::Pass,
1846 summary: AuditSummary {
1847 dead_code_issues: 0,
1848 dead_code_has_errors: false,
1849 complexity_findings: 0,
1850 max_cyclomatic: None,
1851 duplication_clone_groups: 0,
1852 },
1853 attribution: AuditAttribution {
1854 gate: opts.gate,
1855 ..AuditAttribution::default()
1856 },
1857 base_snapshot: None,
1858 base_snapshot_skipped: false,
1859 changed_files_count: 0,
1860 changed_files: Vec::new(),
1861 base_ref,
1862 base_description,
1863 head_sha,
1864 output: opts.output,
1865 performance: opts.performance,
1866 check: None,
1867 dupes: None,
1868 health: None,
1869 elapsed,
1870 review_deltas: None,
1871 weakening_signals: Vec::new(),
1872 routing: None,
1873 decision_surface: None,
1874 graph_snapshot_hash,
1875 change_anchors: Vec::new(),
1876 }
1877}
1878
1879fn run_audit_check<'a>(
1881 opts: &'a AuditOptions<'a>,
1882 changed_since: Option<&'a str>,
1883 retain_modules_for_health: bool,
1884) -> Result<Option<CheckResult>, ExitCode> {
1885 let filters = IssueFilters::default();
1886 let retain_modules_for_health = retain_modules_for_health || opts.brief;
1891 let trace_opts = TraceOptions {
1892 trace_export: None,
1893 trace_file: None,
1894 trace_dependency: None,
1895 impact_closure: None,
1896 performance: opts.performance,
1897 };
1898 match crate::check::execute_check(&CheckOptions {
1899 root: opts.root,
1900 config_path: opts.config_path,
1901 output: opts.output,
1902 no_cache: opts.no_cache,
1903 threads: opts.threads,
1904 quiet: opts.quiet,
1905 fail_on_issues: false,
1906 filters: &filters,
1907 changed_since,
1908 diff_index: None,
1909 use_shared_diff_index: true,
1910 baseline: opts.dead_code_baseline,
1911 save_baseline: None,
1912 sarif_file: None,
1913 production: opts.production_dead_code.unwrap_or(opts.production),
1914 production_override: opts.production_dead_code,
1915 workspace: opts.workspace,
1916 changed_workspaces: opts.changed_workspaces,
1917 group_by: opts.group_by,
1918 include_dupes: false,
1919 trace_opts: &trace_opts,
1920 explain: opts.explain,
1921 top: None,
1922 file: &[],
1923 include_entry_exports: opts.include_entry_exports,
1924 summary: false,
1925 regression_opts: crate::regression::RegressionOpts {
1926 fail_on_regression: false,
1927 tolerance: crate::regression::Tolerance::Absolute(0),
1928 regression_baseline_file: None,
1929 save_target: crate::regression::SaveRegressionTarget::None,
1930 scoped: true,
1931 quiet: opts.quiet,
1932 output: opts.output,
1933 },
1934 retain_modules_for_health,
1935 defer_performance: false,
1936 }) {
1937 Ok(r) => Ok(Some(r)),
1938 Err(code) => Err(code),
1939 }
1940}
1941
1942fn run_audit_dupes<'a>(
1948 opts: &'a AuditOptions<'a>,
1949 changed_since: Option<&'a str>,
1950 changed_files: Option<&'a FxHashSet<PathBuf>>,
1951 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1952) -> Result<Option<DupesResult>, ExitCode> {
1953 let dupes_cfg = match crate::load_config_for_analysis(
1954 opts.root,
1955 opts.config_path,
1956 crate::ConfigLoadOptions {
1957 output: opts.output,
1958 no_cache: opts.no_cache,
1959 threads: opts.threads,
1960 production_override: opts
1961 .production_dupes
1962 .or_else(|| opts.production.then_some(true)),
1963 quiet: opts.quiet,
1964 },
1965 fallow_config::ProductionAnalysis::Dupes,
1966 ) {
1967 Ok(c) => c.duplicates,
1968 Err(code) => return Err(code),
1969 };
1970 let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
1971 let dupes_run = if let Some(files) = pre_discovered {
1972 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1973 } else {
1974 crate::dupes::execute_dupes(&dupes_opts)
1975 };
1976 match dupes_run {
1977 Ok(r) => Ok(Some(r)),
1978 Err(code) => Err(code),
1979 }
1980}
1981
1982fn build_audit_dupes_options<'a>(
1984 opts: &'a AuditOptions<'a>,
1985 changed_since: Option<&'a str>,
1986 changed_files: Option<&'a FxHashSet<PathBuf>>,
1987 dupes_cfg: &fallow_config::DuplicatesConfig,
1988) -> DupesOptions<'a> {
1989 DupesOptions {
1990 root: opts.root,
1991 config_path: opts.config_path,
1992 output: opts.output,
1993 no_cache: opts.no_cache,
1994 threads: opts.threads,
1995 quiet: opts.quiet,
1996 mode: Some(DupesMode::from(dupes_cfg.mode)),
1997 min_tokens: Some(dupes_cfg.min_tokens),
1998 min_lines: Some(dupes_cfg.min_lines),
1999 min_occurrences: Some(dupes_cfg.min_occurrences),
2000 threshold: Some(dupes_cfg.threshold),
2001 skip_local: dupes_cfg.skip_local,
2002 cross_language: dupes_cfg.cross_language,
2003 ignore_imports: Some(dupes_cfg.ignore_imports),
2004 top: None,
2005 baseline_path: opts.dupes_baseline,
2006 save_baseline_path: None,
2007 production: opts.production_dupes.unwrap_or(opts.production),
2008 production_override: opts.production_dupes,
2009 trace: None,
2010 changed_since,
2011 diff_index: None,
2012 use_shared_diff_index: true,
2013 changed_files,
2014 workspace: opts.workspace,
2015 changed_workspaces: opts.changed_workspaces,
2016 explain: opts.explain,
2017 explain_skipped: opts.explain_skipped,
2018 summary: false,
2019 group_by: opts.group_by,
2020 performance: false,
2021 }
2022}
2023
2024fn run_audit_health<'a>(
2026 opts: &'a AuditOptions<'a>,
2027 changed_since: Option<&'a str>,
2028 shared_parse: Option<fallow_engine::health::HealthSharedParseData>,
2029) -> Result<Option<HealthResult>, ExitCode> {
2030 let runtime_coverage = match opts.runtime_coverage {
2031 Some(path) => match crate::health::coverage::prepare_options(
2032 path,
2033 opts.min_invocations_hot,
2034 None,
2035 None,
2036 opts.output,
2037 ) {
2038 Ok(options) => Some(options),
2039 Err(code) => return Err(code),
2040 },
2041 None => None,
2042 };
2043
2044 let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
2045 let health_run = if let Some(shared) = shared_parse {
2046 crate::health::execute_health_with_shared_parse(&health_opts, shared)
2047 } else {
2048 crate::health::execute_health(&health_opts)
2049 };
2050 match health_run {
2051 Ok(r) => Ok(Some(r)),
2052 Err(code) => Err(code),
2053 }
2054}
2055
2056fn build_audit_health_options<'a>(
2059 opts: &'a AuditOptions<'a>,
2060 changed_since: Option<&'a str>,
2061 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
2062) -> HealthOptions<'a> {
2063 HealthOptions {
2064 root: opts.root,
2065 config_path: opts.config_path,
2066 output: opts.output,
2067 no_cache: opts.no_cache,
2068 threads: opts.threads,
2069 quiet: opts.quiet,
2070 thresholds: fallow_engine::health::HealthThresholdOverrides {
2071 max_cyclomatic: None,
2072 max_cognitive: None,
2073 max_crap: opts.max_crap,
2074 },
2075 top: None,
2076 sort: fallow_engine::health::HealthSort::Cyclomatic,
2077 production: opts.production_health.unwrap_or(opts.production),
2078 production_override: opts.production_health,
2079 changed_since,
2080 diff_index: None,
2081 use_shared_diff_index: true,
2082 workspace: opts.workspace,
2083 changed_workspaces: opts.changed_workspaces,
2084 baseline: opts.health_baseline,
2085 save_baseline: None,
2086 complexity: true,
2087 file_scores: false,
2088 coverage_gaps: false,
2089 config_activates_coverage_gaps: false,
2090 hotspots: false,
2091 ownership: false,
2092 ownership_emails: None,
2093 targets: false,
2094 css: opts.css,
2099 css_deep: opts.css_deep,
2100 force_full: false,
2101 score_only_output: false,
2102 enforce_coverage_gap_gate: false,
2103 effort: None,
2104 score: false,
2105 gates: fallow_engine::health::HealthGateOptions::default(),
2106 since: None,
2107 min_commits: None,
2108 explain: opts.explain,
2109 summary: false,
2110 save_snapshot: None,
2111 trend: false,
2112 coverage_inputs: fallow_engine::health::HealthCoverageInputs {
2113 coverage: opts.coverage,
2114 coverage_root: opts.coverage_root,
2115 },
2116 performance: opts.performance,
2117 runtime_coverage,
2118 churn_file: None,
2119 complexity_breakdown: false,
2120 group_by: opts.group_by.map(Into::into),
2121 }
2122}
2123
2124#[path = "audit_output.rs"]
2125mod output;
2126
2127pub use output::audit_json_header_input;
2128pub use output::{
2129 insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
2130 print_audit_findings, print_audit_result,
2131};
2132
2133pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
2139 if let Err(e) = fallow_engine::health::validate_coverage_root_absolute(opts.coverage_root) {
2140 return emit_error(&e, 2, opts.output);
2141 }
2142 let coverage_resolved = opts
2143 .coverage
2144 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2145 let runtime_coverage_resolved = opts
2146 .runtime_coverage
2147 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2148 let resolved_opts = AuditOptions {
2149 coverage: coverage_resolved.as_deref(),
2150 runtime_coverage: runtime_coverage_resolved.as_deref(),
2151 ..*opts
2152 };
2153 match execute_audit(&resolved_opts) {
2154 Ok(result) => {
2155 let mut findings = result
2156 .check
2157 .as_ref()
2158 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
2159 .unwrap_or_default();
2160 if let Some(health) = result.health.as_ref() {
2161 findings.extend(crate::impact::collect_complexity_findings(&health.report));
2162 }
2163 let clones = result
2164 .dupes
2165 .as_ref()
2166 .map(|d| crate::impact::collect_clone_findings(&d.report))
2167 .unwrap_or_default();
2168 let empty_supps: Vec<fallow_types::results::ActiveSuppression> = Vec::new();
2169 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
2170 c.results.active_suppressions.as_slice()
2171 });
2172 let attribution = crate::impact::AttributionInput {
2173 root: opts.root,
2174 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
2175 findings,
2176 clones,
2177 suppressions,
2178 };
2179 crate::impact::record_audit_run(
2180 opts.root,
2181 &result.summary,
2182 &crate::impact::AuditRunRecord {
2183 verdict: result.verdict,
2184 gate: gate_marker.is_some(),
2185 git_sha: result.head_sha.as_deref(),
2186 version: env!("CARGO_PKG_VERSION"),
2187 timestamp: &crate::vital_signs::chrono_timestamp(),
2188 attribution: Some(&attribution),
2189 },
2190 );
2191 if opts.walkthrough_guide {
2194 crate::audit_brief::print_walkthrough_guide_result(&result)
2195 } else if opts.walkthrough {
2196 crate::audit_brief::print_walkthrough_human_result(
2197 &result,
2198 opts.root,
2199 opts.cache_dir,
2200 opts.mark_viewed,
2201 opts.show_cleared,
2202 opts.quiet,
2203 )
2204 } else if let Some(path) = opts.walkthrough_file {
2205 crate::audit_brief::print_walkthrough_file_result(&result, path)
2206 } else if opts.brief {
2207 crate::audit_brief::print_brief_result(
2211 &result,
2212 opts.quiet,
2213 opts.explain,
2214 opts.show_deprioritized,
2215 )
2216 } else {
2217 print_audit_result(&result, opts.quiet, opts.explain)
2218 }
2219 }
2220 Err(code) => code,
2221 }
2222}
2223
2224#[must_use]
2233pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
2234 let brief_opts = AuditOptions {
2236 brief: true,
2237 ..*opts
2238 };
2239 match execute_audit(&brief_opts) {
2240 Ok(result) => crate::audit_brief::print_decision_surface_result(&result, opts.quiet),
2241 Err(code) => code,
2242 }
2243}
2244
2245#[cfg(test)]
2246#[path = "audit_tests.rs"]
2247mod tests;