1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::io::{IsTerminal, Write};
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitCode};
6use std::time::{Duration, Instant};
7
8use colored::Colorize;
9use fallow_config::{AuditGate, OutputFormat};
10use fallow_core::git_env::clear_ambient_git_env;
11use rustc_hash::FxHashSet;
12use xxhash_rust::xxh3::xxh3_64;
13
14use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
15use crate::dupes::{DupesMode, DupesOptions, DupesResult};
16use crate::error::emit_error;
17use crate::health::{HealthOptions, HealthResult, SortBy};
18use crate::report;
19use crate::report::plural;
20
21const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 2;
24const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024;
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[serde(rename_all = "snake_case")]
30pub enum AuditVerdict {
31 Pass,
33 Warn,
35 Fail,
37}
38
39#[derive(Debug, Clone, serde::Serialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct AuditSummary {
43 pub dead_code_issues: usize,
44 pub dead_code_has_errors: bool,
45 pub complexity_findings: usize,
46 pub max_cyclomatic: Option<u16>,
47 pub duplication_clone_groups: usize,
48}
49
50#[derive(Debug, Default, Clone, serde::Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53pub struct AuditAttribution {
54 pub gate: AuditGate,
55 pub dead_code_introduced: usize,
56 pub dead_code_inherited: usize,
57 pub complexity_introduced: usize,
58 pub complexity_inherited: usize,
59 pub duplication_introduced: usize,
60 pub duplication_inherited: usize,
61}
62
63pub struct AuditResult {
65 pub verdict: AuditVerdict,
66 pub summary: AuditSummary,
67 pub attribution: AuditAttribution,
68 base_snapshot: Option<AuditKeySnapshot>,
69 pub base_snapshot_skipped: bool,
70 pub changed_files_count: usize,
71 pub base_ref: String,
72 pub head_sha: Option<String>,
73 pub output: OutputFormat,
74 pub performance: bool,
75 pub check: Option<CheckResult>,
76 pub dupes: Option<DupesResult>,
77 pub health: Option<HealthResult>,
78 pub elapsed: Duration,
79}
80
81pub struct AuditOptions<'a> {
82 pub root: &'a std::path::Path,
83 pub config_path: &'a Option<std::path::PathBuf>,
84 pub output: OutputFormat,
85 pub no_cache: bool,
86 pub threads: usize,
87 pub quiet: bool,
88 pub changed_since: Option<&'a str>,
89 pub production: bool,
90 pub production_dead_code: Option<bool>,
91 pub production_health: Option<bool>,
92 pub production_dupes: Option<bool>,
93 pub workspace: Option<&'a [String]>,
94 pub changed_workspaces: Option<&'a str>,
95 pub explain: bool,
96 pub explain_skipped: bool,
97 pub performance: bool,
98 pub group_by: Option<crate::GroupBy>,
99 pub dead_code_baseline: Option<&'a std::path::Path>,
101 pub health_baseline: Option<&'a std::path::Path>,
103 pub dupes_baseline: Option<&'a std::path::Path>,
105 pub max_crap: Option<f64>,
108 pub coverage: Option<&'a std::path::Path>,
110 pub coverage_root: Option<&'a std::path::Path>,
112 pub gate: AuditGate,
113 pub include_entry_exports: bool,
115 pub runtime_coverage: Option<&'a std::path::Path>,
121 pub min_invocations_hot: u64,
123 }
129
130fn auto_detect_base_branch(root: &std::path::Path) -> Option<String> {
136 let mut symbolic_ref = std::process::Command::new("git");
138 symbolic_ref
139 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
140 .current_dir(root);
141 clear_ambient_git_env(&mut symbolic_ref);
142 if let Ok(output) = symbolic_ref.output()
143 && output.status.success()
144 {
145 let full_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
146 if let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/") {
147 return Some(branch.to_string());
148 }
149 }
150
151 let mut verify_main = std::process::Command::new("git");
153 verify_main
154 .args(["rev-parse", "--verify", "main"])
155 .current_dir(root);
156 clear_ambient_git_env(&mut verify_main);
157 if let Ok(output) = verify_main.output()
158 && output.status.success()
159 {
160 return Some("main".to_string());
161 }
162
163 let mut verify_master = std::process::Command::new("git");
165 verify_master
166 .args(["rev-parse", "--verify", "master"])
167 .current_dir(root);
168 clear_ambient_git_env(&mut verify_master);
169 if let Ok(output) = verify_master.output()
170 && output.status.success()
171 {
172 return Some("master".to_string());
173 }
174
175 None
176}
177
178fn get_head_sha(root: &std::path::Path) -> Option<String> {
180 let mut command = std::process::Command::new("git");
181 command
182 .args(["rev-parse", "--short", "HEAD"])
183 .current_dir(root);
184 clear_ambient_git_env(&mut command);
185 let output = command.output().ok()?;
186 if output.status.success() {
187 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
188 } else {
189 None
190 }
191}
192
193fn compute_verdict(
196 check: Option<&CheckResult>,
197 dupes: Option<&DupesResult>,
198 health: Option<&HealthResult>,
199) -> AuditVerdict {
200 let mut has_errors = false;
201 let mut has_warnings = false;
202
203 if let Some(result) = check {
205 if crate::check::has_error_severity_issues(
206 &result.results,
207 &result.config.rules,
208 Some(&result.config),
209 ) {
210 has_errors = true;
211 } else if result.results.total_issues() > 0 {
212 has_warnings = true;
213 }
214 }
215
216 if let Some(result) = health
220 && !result.report.findings.is_empty()
221 {
222 has_errors = true;
223 }
224
225 if let Some(result) = dupes
227 && !result.report.clone_groups.is_empty()
228 {
229 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
230 has_errors = true;
231 } else {
232 has_warnings = true;
233 }
234 }
235
236 if has_errors {
237 AuditVerdict::Fail
238 } else if has_warnings {
239 AuditVerdict::Warn
240 } else {
241 AuditVerdict::Pass
242 }
243}
244
245fn build_summary(
246 check: Option<&CheckResult>,
247 dupes: Option<&DupesResult>,
248 health: Option<&HealthResult>,
249) -> AuditSummary {
250 let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
251 let dead_code_has_errors = check.is_some_and(|r| {
252 crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
253 });
254 let complexity_findings = health.map_or(0, |r| r.report.findings.len());
255 let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
256 let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
257
258 AuditSummary {
259 dead_code_issues,
260 dead_code_has_errors,
261 complexity_findings,
262 max_cyclomatic,
263 duplication_clone_groups,
264 }
265}
266
267fn compute_audit_attribution(
268 check: Option<&CheckResult>,
269 dupes: Option<&DupesResult>,
270 health: Option<&HealthResult>,
271 base: Option<&AuditKeySnapshot>,
272 gate: AuditGate,
273) -> AuditAttribution {
274 let dead_code = check
275 .map(|r| {
276 count_introduced(
277 &dead_code_keys(&r.results, &r.config.root),
278 base.map(|b| &b.dead_code),
279 )
280 })
281 .unwrap_or_default();
282 let complexity = health
283 .map(|r| {
284 count_introduced(
285 &health_keys(&r.report, &r.config.root),
286 base.map(|b| &b.health),
287 )
288 })
289 .unwrap_or_default();
290 let duplication = dupes
291 .map(|r| {
292 count_introduced(
293 &dupes_keys(&r.report, &r.config.root),
294 base.map(|b| &b.dupes),
295 )
296 })
297 .unwrap_or_default();
298
299 AuditAttribution {
300 gate,
301 dead_code_introduced: dead_code.0,
302 dead_code_inherited: dead_code.1,
303 complexity_introduced: complexity.0,
304 complexity_inherited: complexity.1,
305 duplication_introduced: duplication.0,
306 duplication_inherited: duplication.1,
307 }
308}
309
310fn compute_introduced_verdict(
311 check: Option<&CheckResult>,
312 dupes: Option<&DupesResult>,
313 health: Option<&HealthResult>,
314 base: Option<&AuditKeySnapshot>,
315) -> AuditVerdict {
316 let mut has_errors = false;
317 let mut has_warnings = false;
318
319 if let Some(result) = check {
320 let base_keys = base.map(|b| &b.dead_code);
321 let mut introduced = result.results.clone();
322 retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
323 if crate::check::has_error_severity_issues(
324 &introduced,
325 &result.config.rules,
326 Some(&result.config),
327 ) {
328 has_errors = true;
329 } else if introduced.total_issues() > 0 {
330 has_warnings = true;
331 }
332 }
333
334 if let Some(result) = health {
335 let base_keys = base.map(|b| &b.health);
336 let introduced = result
337 .report
338 .findings
339 .iter()
340 .filter(|finding| {
341 !base_keys.is_some_and(|keys| {
342 keys.contains(&health_finding_key(finding, &result.config.root))
343 })
344 })
345 .count();
346 if introduced > 0 {
347 has_errors = true;
348 }
349 }
350
351 if let Some(result) = dupes {
352 let base_keys = base.map(|b| &b.dupes);
353 let introduced = result
354 .report
355 .clone_groups
356 .iter()
357 .filter(|group| {
358 !base_keys
359 .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
360 })
361 .count();
362 if introduced > 0 {
363 if result.threshold > 0.0
364 && result.report.stats.duplication_percentage > result.threshold
365 {
366 has_errors = true;
367 } else {
368 has_warnings = true;
369 }
370 }
371 }
372
373 if has_errors {
374 AuditVerdict::Fail
375 } else if has_warnings {
376 AuditVerdict::Warn
377 } else {
378 AuditVerdict::Pass
379 }
380}
381
382struct AuditKeySnapshot {
383 dead_code: FxHashSet<String>,
384 health: FxHashSet<String>,
385 dupes: FxHashSet<String>,
386}
387
388struct AuditBaseSnapshotCacheKey {
389 hash: u64,
390 base_sha: String,
391}
392
393#[derive(bitcode::Encode, bitcode::Decode)]
394struct CachedAuditKeySnapshot {
395 version: u8,
396 cli_version: String,
397 key_hash: u64,
398 base_sha: String,
399 dead_code: Vec<String>,
400 health: Vec<String>,
401 dupes: Vec<String>,
402}
403
404fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
405 let Some(base) = base else {
406 return (0, 0);
407 };
408 keys.iter().fold((0, 0), |(introduced, inherited), key| {
409 if base.contains(key) {
410 (introduced, inherited + 1)
411 } else {
412 (introduced + 1, inherited)
413 }
414 })
415}
416
417fn sorted_keys(keys: &FxHashSet<String>) -> Vec<String> {
418 let mut keys: Vec<String> = keys.iter().cloned().collect();
419 keys.sort_unstable();
420 keys
421}
422
423fn snapshot_from_cached(cached: CachedAuditKeySnapshot) -> AuditKeySnapshot {
424 AuditKeySnapshot {
425 dead_code: cached.dead_code.into_iter().collect(),
426 health: cached.health.into_iter().collect(),
427 dupes: cached.dupes.into_iter().collect(),
428 }
429}
430
431fn cached_from_snapshot(
432 key: &AuditBaseSnapshotCacheKey,
433 snapshot: &AuditKeySnapshot,
434) -> CachedAuditKeySnapshot {
435 CachedAuditKeySnapshot {
436 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
437 cli_version: env!("CARGO_PKG_VERSION").to_string(),
438 key_hash: key.hash,
439 base_sha: key.base_sha.clone(),
440 dead_code: sorted_keys(&snapshot.dead_code),
441 health: sorted_keys(&snapshot.health),
442 dupes: sorted_keys(&snapshot.dupes),
443 }
444}
445
446fn audit_base_snapshot_cache_dir(root: &Path) -> PathBuf {
447 root.join(".fallow")
448 .join("cache")
449 .join(format!("audit-base-v{AUDIT_BASE_SNAPSHOT_CACHE_VERSION}"))
450}
451
452fn audit_base_snapshot_cache_file(root: &Path, key: &AuditBaseSnapshotCacheKey) -> PathBuf {
453 audit_base_snapshot_cache_dir(root).join(format!("{:016x}.bin", key.hash))
454}
455
456fn ensure_audit_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
457 std::fs::create_dir_all(dir)?;
458 let gitignore = dir.join(".gitignore");
459 if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
460 std::fs::write(gitignore, "*\n")?;
461 }
462 Ok(())
463}
464
465fn load_cached_base_snapshot(
466 opts: &AuditOptions<'_>,
467 key: &AuditBaseSnapshotCacheKey,
468) -> Option<AuditKeySnapshot> {
469 let path = audit_base_snapshot_cache_file(opts.root, key);
470 let data = std::fs::read(path).ok()?;
471 if data.len() > MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE {
472 return None;
473 }
474 let cached: CachedAuditKeySnapshot = bitcode::decode(&data).ok()?;
475 if cached.version != AUDIT_BASE_SNAPSHOT_CACHE_VERSION
476 || cached.cli_version != env!("CARGO_PKG_VERSION")
477 || cached.key_hash != key.hash
478 || cached.base_sha != key.base_sha
479 {
480 return None;
481 }
482 Some(snapshot_from_cached(cached))
483}
484
485fn save_cached_base_snapshot(
486 opts: &AuditOptions<'_>,
487 key: &AuditBaseSnapshotCacheKey,
488 snapshot: &AuditKeySnapshot,
489) {
490 let dir = audit_base_snapshot_cache_dir(opts.root);
491 if ensure_audit_base_snapshot_cache_dir(&dir).is_err() {
492 return;
493 }
494 let data = bitcode::encode(&cached_from_snapshot(key, snapshot));
495 let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
496 return;
497 };
498 if tmp.write_all(&data).is_err() {
499 return;
500 }
501 let _ = tmp.persist(audit_base_snapshot_cache_file(opts.root, key));
502}
503
504fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
505 let mut command = Command::new("git");
506 command.args(["rev-parse", rev]).current_dir(root);
507 clear_ambient_git_env(&mut command);
508 let output = command.output().ok()?;
509 if !output.status.success() {
510 return None;
511 }
512 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
513}
514
515fn ambient_git_env_hint() -> Option<String> {
520 use fallow_core::git_env::AMBIENT_GIT_ENV_VARS;
521 for var in AMBIENT_GIT_ENV_VARS {
522 if let Ok(value) = std::env::var(var)
523 && !value.is_empty()
524 {
525 return Some(format!(
526 "{var}={value} is set in the environment; if fallow is being \
527invoked from a git hook this can interfere with worktree operations. Re-run \
528with `env -u {var} fallow audit` to confirm."
529 ));
530 }
531 }
532 None
533}
534
535fn normalized_changed_files(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
536 let git_root = git_toplevel(root);
537 let mut files: Vec<String> = changed_files
538 .iter()
539 .map(|path| {
540 git_root
541 .as_ref()
542 .and_then(|root| path.strip_prefix(root).ok())
543 .unwrap_or(path)
544 .to_string_lossy()
545 .replace('\\', "/")
546 })
547 .collect();
548 files.sort_unstable();
549 files
550}
551
552fn config_file_fingerprint(opts: &AuditOptions<'_>) -> Result<serde_json::Value, ExitCode> {
553 let loaded = if let Some(path) = opts.config_path {
554 let config = fallow_config::FallowConfig::load(path).map_err(|e| {
555 emit_error(
556 &format!("failed to load config '{}': {e}", path.display()),
557 2,
558 opts.output,
559 )
560 })?;
561 Some((config, path.clone()))
562 } else {
563 fallow_config::FallowConfig::find_and_load(opts.root)
564 .map_err(|e| emit_error(&e, 2, opts.output))?
565 };
566
567 let Some((config, path)) = loaded else {
568 return Ok(serde_json::json!({
569 "path": null,
570 "resolved_hash": null,
571 }));
572 };
573 let bytes = serde_json::to_vec(&config).map_err(|e| {
574 emit_error(
575 &format!("failed to serialize resolved config for audit cache key: {e}"),
576 2,
577 opts.output,
578 )
579 })?;
580 Ok(serde_json::json!({
581 "path": path.to_string_lossy(),
582 "resolved_hash": format!("{:016x}", xxh3_64(&bytes)),
583 }))
584}
585
586fn coverage_file_fingerprint(path: &Path, project_root: &Path) -> serde_json::Value {
587 let resolved = crate::health::scoring::resolve_relative_to_root(path, Some(project_root));
588 let file_path = if resolved.is_dir() {
589 resolved.join("coverage-final.json")
590 } else {
591 resolved
592 };
593 match std::fs::read(&file_path) {
594 Ok(bytes) => serde_json::json!({
595 "path": path.to_string_lossy(),
596 "resolved_path": file_path.to_string_lossy(),
597 "content_hash": format!("{:016x}", xxh3_64(&bytes)),
598 "len": bytes.len(),
599 }),
600 Err(err) => serde_json::json!({
601 "path": path.to_string_lossy(),
602 "resolved_path": file_path.to_string_lossy(),
603 "error": err.kind().to_string(),
604 }),
605 }
606}
607
608fn audit_base_snapshot_cache_key(
609 opts: &AuditOptions<'_>,
610 base_ref: &str,
611 changed_files: &FxHashSet<PathBuf>,
612) -> Result<Option<AuditBaseSnapshotCacheKey>, ExitCode> {
613 if opts.no_cache {
614 return Ok(None);
615 }
616 let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
617 return Ok(None);
618 };
619 let config_file = config_file_fingerprint(opts)?;
620 let coverage_file = opts
621 .coverage
622 .map(|p| coverage_file_fingerprint(p, opts.root));
623 let payload = serde_json::json!({
624 "cache_version": AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
625 "cli_version": env!("CARGO_PKG_VERSION"),
626 "base_sha": base_sha,
627 "config_file": config_file,
628 "changed_files": normalized_changed_files(opts.root, changed_files),
629 "production": opts.production,
630 "production_dead_code": opts.production_dead_code,
631 "production_health": opts.production_health,
632 "production_dupes": opts.production_dupes,
633 "workspace": opts.workspace,
634 "changed_workspaces": opts.changed_workspaces,
635 "group_by": opts.group_by.map(|g| format!("{g:?}")),
636 "include_entry_exports": opts.include_entry_exports,
637 "max_crap": opts.max_crap,
638 "coverage": coverage_file,
639 "coverage_root": opts.coverage_root.map(|p| p.to_string_lossy().to_string()),
640 "dead_code_baseline": opts.dead_code_baseline.map(|p| p.to_string_lossy().to_string()),
641 "health_baseline": opts.health_baseline.map(|p| p.to_string_lossy().to_string()),
642 "dupes_baseline": opts.dupes_baseline.map(|p| p.to_string_lossy().to_string()),
643 });
644 let bytes = serde_json::to_vec(&payload).map_err(|e| {
645 emit_error(
646 &format!("failed to build audit cache key: {e}"),
647 2,
648 opts.output,
649 )
650 })?;
651 Ok(Some(AuditBaseSnapshotCacheKey {
652 hash: xxh3_64(&bytes),
653 base_sha,
654 }))
655}
656
657fn compute_base_snapshot(
658 opts: &AuditOptions<'_>,
659 base_ref: &str,
660 changed_files: &FxHashSet<PathBuf>,
661 base_sha: Option<&str>,
662) -> Result<AuditKeySnapshot, ExitCode> {
663 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
664 use std::fmt::Write as _;
665 let mut message =
666 format!("could not create a temporary worktree for base ref '{base_ref}'");
667 if let Some(hint) = ambient_git_env_hint() {
668 let _ = write!(message, "\n hint: {hint}");
669 }
670 return Err(emit_error(&message, 2, opts.output));
671 };
672 let base_root = base_analysis_root(opts.root, worktree.path());
673 let current_config_path = opts
674 .config_path
675 .clone()
676 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
677 let base_opts = AuditOptions {
678 root: &base_root,
679 config_path: ¤t_config_path,
680 output: opts.output,
681 no_cache: opts.no_cache,
682 threads: opts.threads,
683 quiet: true,
684 changed_since: None,
685 production: opts.production,
686 production_dead_code: opts.production_dead_code,
687 production_health: opts.production_health,
688 production_dupes: opts.production_dupes,
689 workspace: opts.workspace,
690 changed_workspaces: None,
691 explain: false,
692 explain_skipped: false,
693 performance: false,
694 group_by: opts.group_by,
695 dead_code_baseline: None,
696 health_baseline: None,
697 dupes_baseline: None,
698 max_crap: opts.max_crap,
699 coverage: opts.coverage,
700 coverage_root: opts.coverage_root,
701 gate: AuditGate::All,
702 include_entry_exports: opts.include_entry_exports,
703 runtime_coverage: None,
710 min_invocations_hot: opts.min_invocations_hot,
711 };
712
713 let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
714 let check_production = opts.production_dead_code.unwrap_or(opts.production);
715 let health_production = opts.production_health.unwrap_or(opts.production);
716 let share_dead_code_parse_with_health = check_production == health_production;
717
718 let (check_res, dupes_res) = rayon::join(
723 || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
724 || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
725 );
726 let mut check = check_res?;
727 let dupes = dupes_res?;
728 let shared_parse = if share_dead_code_parse_with_health {
729 check.as_mut().and_then(|r| r.shared_parse.take())
730 } else {
731 None
732 };
733 let health = run_audit_health(&base_opts, None, shared_parse)?;
734 if let Some(ref mut check) = check {
735 check.shared_parse = None;
736 }
737
738 Ok(AuditKeySnapshot {
739 dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
740 dead_code_keys(&r.results, &r.config.root)
741 }),
742 health: health.as_ref().map_or_else(FxHashSet::default, |r| {
743 health_keys(&r.report, &r.config.root)
744 }),
745 dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
746 dupes_keys(&r.report, &r.config.root)
747 }),
748 })
749}
750
751fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
752 let Some(git_root) = git_toplevel(current_root) else {
753 return base_worktree_root.to_path_buf();
754 };
755 let current_root = current_root
756 .canonicalize()
757 .unwrap_or_else(|_| current_root.to_path_buf());
758 match current_root.strip_prefix(&git_root) {
759 Ok(relative) => base_worktree_root.join(relative),
760 Err(err) => {
761 tracing::warn!(
762 current_root = %current_root.display(),
763 git_root = %git_root.display(),
764 error = %err,
765 "Could not remap audit base root into the base worktree; falling back to worktree root"
766 );
767 base_worktree_root.to_path_buf()
768 }
769 }
770}
771
772fn current_keys_as_base_keys(
773 check: Option<&CheckResult>,
774 dupes: Option<&DupesResult>,
775 health: Option<&HealthResult>,
776) -> AuditKeySnapshot {
777 AuditKeySnapshot {
778 dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
779 dead_code_keys(&r.results, &r.config.root)
780 }),
781 health: health.as_ref().map_or_else(FxHashSet::default, |r| {
782 health_keys(&r.report, &r.config.root)
783 }),
784 dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
785 dupes_keys(&r.report, &r.config.root)
786 }),
787 }
788}
789
790fn can_reuse_current_as_base(
791 opts: &AuditOptions<'_>,
792 base_ref: &str,
793 changed_files: &FxHashSet<PathBuf>,
794) -> bool {
795 let Some(git_root) = git_toplevel(opts.root) else {
796 return false;
797 };
798 let cache_dir = opts.root.join(".fallow");
803 let canonical_cache_dir = cache_dir.canonicalize().ok();
804 changed_files.iter().all(|path| {
805 if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
806 return true;
807 }
808 if !is_analysis_input(path) {
809 return is_non_behavioral_doc(path);
810 }
811 let Ok(current) = std::fs::read_to_string(path) else {
812 return false;
813 };
814 let Some(relative) = path.strip_prefix(&git_root).ok() else {
815 return false;
816 };
817 let Some(base) = git_show_file(opts.root, base_ref, relative) else {
818 return false;
819 };
820 if current == base {
821 return true;
822 }
823 js_ts_tokens_equivalent(path, ¤t, &base)
824 })
825}
826
827fn is_fallow_cache_artifact(
835 path: &Path,
836 cache_dir: &Path,
837 canonical_cache_dir: Option<&Path>,
838) -> bool {
839 path.starts_with(cache_dir)
840 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
841}
842
843fn git_toplevel(root: &Path) -> Option<PathBuf> {
844 let mut command = Command::new("git");
845 command
846 .args(["rev-parse", "--show-toplevel"])
847 .current_dir(root);
848 clear_ambient_git_env(&mut command);
849 let output = command.output().ok()?;
850 if !output.status.success() {
851 return None;
852 }
853 let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
854 Some(path.canonicalize().unwrap_or(path))
855}
856
857fn git_show_file(root: &Path, base_ref: &str, relative: &Path) -> Option<String> {
858 let spec = format!(
859 "{}:{}",
860 base_ref,
861 relative.to_string_lossy().replace('\\', "/")
862 );
863 let mut command = Command::new("git");
864 command
865 .args(["show", "--end-of-options", &spec])
866 .current_dir(root);
867 clear_ambient_git_env(&mut command);
868 let output = command.output().ok()?;
869 output
870 .status
871 .success()
872 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
873}
874
875fn is_analysis_input(path: &Path) -> bool {
876 matches!(
877 path.extension().and_then(|ext| ext.to_str()),
878 Some(
879 "js" | "jsx"
880 | "ts"
881 | "tsx"
882 | "mjs"
883 | "mts"
884 | "cjs"
885 | "cts"
886 | "vue"
887 | "svelte"
888 | "astro"
889 | "mdx"
890 | "css"
891 | "scss"
892 )
893 )
894}
895
896fn is_non_behavioral_doc(path: &Path) -> bool {
897 matches!(
898 path.extension().and_then(|ext| ext.to_str()),
899 Some("md" | "markdown" | "txt" | "rst" | "adoc")
900 )
901}
902
903fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
904 if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
905 return false;
906 }
907 if !matches!(
908 path.extension().and_then(|ext| ext.to_str()),
909 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
910 ) {
911 return false;
912 }
913 let current_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, current, false);
914 let base_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, base, false);
915 current_tokens
916 .tokens
917 .iter()
918 .map(|token| &token.kind)
919 .eq(base_tokens.tokens.iter().map(|token| &token.kind))
920}
921
922fn remap_focus_files(
940 files: &FxHashSet<PathBuf>,
941 from_root: &Path,
942 to_root: &Path,
943) -> Option<FxHashSet<PathBuf>> {
944 let mut remapped = FxHashSet::default();
945 for file in files {
946 if let Ok(relative) = file.strip_prefix(from_root) {
947 remapped.insert(to_root.join(relative));
948 }
949 }
950 if remapped.is_empty() {
951 return None;
952 }
953 Some(remapped)
954}
955
956struct BaseWorktree {
957 repo_root: PathBuf,
958 path: PathBuf,
959 persistent: bool,
960}
961
962impl BaseWorktree {
963 fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
964 sweep_orphan_audit_worktrees(repo_root);
965 if let Some(base_sha) = base_sha
966 && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
967 {
968 return Some(worktree);
969 }
970 let path = std::env::temp_dir().join(format!(
971 "fallow-audit-base-{}-{}",
972 std::process::id(),
973 std::time::SystemTime::now()
974 .duration_since(std::time::UNIX_EPOCH)
975 .ok()?
976 .as_nanos()
977 ));
978 let mut command = Command::new("git");
979 command
980 .args([
981 "worktree",
982 "add",
983 "--detach",
984 "--quiet",
985 path.to_str()?,
986 base_ref,
987 ])
988 .current_dir(repo_root);
989 clear_ambient_git_env(&mut command);
990 let output = command.output().ok()?;
991 if !output.status.success() {
992 let _ = std::fs::remove_dir_all(&path);
993 return None;
994 }
995 let worktree = Self {
996 repo_root: repo_root.to_path_buf(),
997 path,
998 persistent: false,
999 };
1000 materialize_base_dependency_context(repo_root, worktree.path());
1001 Some(worktree)
1002 }
1003
1004 fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
1005 let path = reusable_audit_worktree_path(repo_root, base_sha);
1006 if reusable_audit_worktree_is_ready(repo_root, &path, base_sha) {
1007 let worktree = Self {
1008 repo_root: repo_root.to_path_buf(),
1009 path,
1010 persistent: true,
1011 };
1012 materialize_base_dependency_context(repo_root, worktree.path());
1013 return Some(worktree);
1014 }
1015
1016 remove_audit_worktree(repo_root, &path);
1017 let _ = std::fs::remove_dir_all(&path);
1018 let mut command = Command::new("git");
1019 command
1020 .args([
1021 "worktree",
1022 "add",
1023 "--detach",
1024 "--quiet",
1025 path.to_string_lossy().as_ref(),
1026 base_sha,
1027 ])
1028 .current_dir(repo_root);
1029 clear_ambient_git_env(&mut command);
1030 let output = command.output().ok()?;
1031 if !output.status.success() {
1032 let _ = std::fs::remove_dir_all(&path);
1033 return None;
1034 }
1035
1036 let worktree = Self {
1037 repo_root: repo_root.to_path_buf(),
1038 path,
1039 persistent: true,
1040 };
1041 materialize_base_dependency_context(repo_root, worktree.path());
1042 Some(worktree)
1043 }
1044
1045 fn path(&self) -> &Path {
1046 &self.path
1047 }
1048}
1049
1050fn reusable_audit_worktree_path(repo_root: &Path, base_sha: &str) -> PathBuf {
1051 let repo_root = git_toplevel(repo_root).unwrap_or_else(|| repo_root.to_path_buf());
1052 let repo_root = repo_root.canonicalize().unwrap_or(repo_root);
1053 let repo_hash = xxh3_64(repo_root.to_string_lossy().as_bytes());
1054 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1055 std::env::temp_dir().join(format!(
1056 "fallow-audit-base-cache-{repo_hash:016x}-{sha_prefix}"
1057 ))
1058}
1059
1060fn reusable_audit_worktree_is_ready(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1061 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1062 return false;
1063 }
1064 git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha)
1065}
1066
1067fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1068 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1069 return false;
1070 };
1071 worktrees.iter().any(|worktree| paths_equal(worktree, path))
1072}
1073
1074fn paths_equal(left: &Path, right: &Path) -> bool {
1075 if left == right {
1076 return true;
1077 }
1078 match (left.canonicalize(), right.canonicalize()) {
1079 (Ok(left), Ok(right)) => left == right,
1080 _ => false,
1081 }
1082}
1083
1084fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
1085 let source = repo_root.join("node_modules");
1086 if !source.is_dir() {
1087 return;
1088 }
1089
1090 let destination = worktree_path.join("node_modules");
1091 if destination.is_dir() {
1092 return;
1093 }
1094 if let Ok(metadata) = std::fs::symlink_metadata(&destination) {
1095 if !metadata.file_type().is_symlink() {
1096 return;
1097 }
1098 let _ = std::fs::remove_file(&destination);
1099 }
1100
1101 let _ = symlink_dependency_dir(&source, &destination);
1102}
1103
1104#[cfg(unix)]
1105fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1106 std::os::unix::fs::symlink(source, destination)
1107}
1108
1109#[cfg(windows)]
1110fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1111 std::os::windows::fs::symlink_dir(source, destination)
1112}
1113
1114fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1115 let mut command = Command::new("git");
1116 command
1117 .args([
1118 "worktree",
1119 "remove",
1120 "--force",
1121 path.to_string_lossy().as_ref(),
1122 ])
1123 .current_dir(repo_root);
1124 clear_ambient_git_env(&mut command);
1125 let _ = command.output();
1126}
1127
1128fn sweep_orphan_audit_worktrees(repo_root: &Path) {
1129 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1130 return;
1131 };
1132 let mut removed_any = false;
1133 for path in worktrees {
1134 if !is_fallow_audit_worktree_path(&path)
1135 || is_reusable_audit_worktree_path(&path)
1136 || audit_worktree_process_is_alive(&path)
1137 {
1138 continue;
1139 }
1140 remove_audit_worktree(repo_root, &path);
1141 let _ = std::fs::remove_dir_all(&path);
1142 removed_any = true;
1143 }
1144 if removed_any {
1145 let mut command = Command::new("git");
1146 command
1147 .args(["worktree", "prune", "--expire=now"])
1148 .current_dir(repo_root);
1149 clear_ambient_git_env(&mut command);
1150 let _ = command.output();
1151 }
1152}
1153
1154fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
1155 let mut command = Command::new("git");
1156 command
1157 .args(["worktree", "list", "--porcelain"])
1158 .current_dir(repo_root);
1159 clear_ambient_git_env(&mut command);
1160 let output = command.output().ok()?;
1161 if !output.status.success() {
1162 return None;
1163 }
1164 Some(parse_worktree_list(&String::from_utf8_lossy(
1165 &output.stdout,
1166 )))
1167}
1168
1169fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
1170 output
1171 .lines()
1172 .filter_map(|line| line.strip_prefix("worktree "))
1173 .map(PathBuf::from)
1174 .filter(|path| is_fallow_audit_worktree_path(path))
1175 .collect()
1176}
1177
1178fn is_fallow_audit_worktree_path(path: &Path) -> bool {
1179 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1180 return false;
1181 };
1182 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
1183}
1184
1185fn is_reusable_audit_worktree_path(path: &Path) -> bool {
1186 path.file_name()
1187 .and_then(|name| name.to_str())
1188 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
1189}
1190
1191fn path_is_inside_temp_dir(path: &Path) -> bool {
1192 let temp = std::env::temp_dir();
1193 if path.starts_with(&temp) {
1194 return true;
1195 }
1196 let Ok(canonical_temp) = temp.canonicalize() else {
1197 return false;
1198 };
1199 path.starts_with(&canonical_temp)
1200 || path
1201 .canonicalize()
1202 .is_ok_and(|canonical_path| canonical_path.starts_with(canonical_temp))
1203}
1204
1205fn audit_worktree_process_is_alive(path: &Path) -> bool {
1206 let Some(pid) = path
1207 .file_name()
1208 .and_then(|name| name.to_str())
1209 .and_then(audit_worktree_pid)
1210 else {
1211 return false;
1212 };
1213 process_is_alive(pid)
1214}
1215
1216fn audit_worktree_pid(name: &str) -> Option<u32> {
1217 name.strip_prefix("fallow-audit-base-")?
1218 .split('-')
1219 .next()?
1220 .parse()
1221 .ok()
1222}
1223
1224#[cfg(unix)]
1225fn process_is_alive(pid: u32) -> bool {
1226 Command::new("kill")
1227 .args(["-0", &pid.to_string()])
1228 .output()
1229 .is_ok_and(|output| output.status.success())
1230}
1231
1232#[cfg(not(unix))]
1233fn process_is_alive(_pid: u32) -> bool {
1234 true
1235}
1236
1237impl Drop for BaseWorktree {
1238 fn drop(&mut self) {
1239 if self.persistent {
1240 return;
1241 }
1242 remove_audit_worktree(&self.repo_root, &self.path);
1243 let _ = std::fs::remove_dir_all(&self.path);
1244 }
1245}
1246
1247fn relative_key_path(path: &Path, root: &Path) -> String {
1248 path.strip_prefix(root)
1249 .unwrap_or(path)
1250 .to_string_lossy()
1251 .replace('\\', "/")
1252}
1253
1254fn dependency_location_key(location: &fallow_core::results::DependencyLocation) -> &'static str {
1255 match location {
1256 fallow_core::results::DependencyLocation::Dependencies => "unused-dependency",
1257 fallow_core::results::DependencyLocation::DevDependencies => "unused-dev-dependency",
1258 fallow_core::results::DependencyLocation::OptionalDependencies => {
1259 "unused-optional-dependency"
1260 }
1261 }
1262}
1263
1264fn unused_dependency_key(item: &fallow_core::results::UnusedDependency, root: &Path) -> String {
1265 format!(
1266 "{}:{}:{}",
1267 dependency_location_key(&item.location),
1268 relative_key_path(&item.path, root),
1269 item.package_name
1270 )
1271}
1272
1273fn unlisted_dependency_key(item: &fallow_core::results::UnlistedDependency, root: &Path) -> String {
1274 let mut sites = item
1275 .imported_from
1276 .iter()
1277 .map(|site| {
1278 format!(
1279 "{}:{}:{}",
1280 relative_key_path(&site.path, root),
1281 site.line,
1282 site.col
1283 )
1284 })
1285 .collect::<Vec<_>>();
1286 sites.sort();
1287 sites.dedup();
1288 format!(
1289 "unlisted-dependency:{}:{}",
1290 item.package_name,
1291 sites.join("|")
1292 )
1293}
1294
1295fn unused_member_key(
1296 rule_id: &str,
1297 item: &fallow_core::results::UnusedMember,
1298 root: &Path,
1299) -> String {
1300 format!(
1301 "{}:{}:{}:{}",
1302 rule_id,
1303 relative_key_path(&item.path, root),
1304 item.parent_name,
1305 item.member_name
1306 )
1307}
1308
1309fn unused_catalog_entry_key(
1310 item: &fallow_core::results::UnusedCatalogEntry,
1311 root: &Path,
1312) -> String {
1313 format!(
1314 "unused-catalog-entry:{}:{}:{}:{}",
1315 relative_key_path(&item.path, root),
1316 item.line,
1317 item.catalog_name,
1318 item.entry_name
1319 )
1320}
1321
1322fn empty_catalog_group_key(item: &fallow_core::results::EmptyCatalogGroup, root: &Path) -> String {
1323 format!(
1324 "empty-catalog-group:{}:{}:{}",
1325 relative_key_path(&item.path, root),
1326 item.line,
1327 item.catalog_name
1328 )
1329}
1330
1331fn dead_code_keys(
1332 results: &fallow_core::results::AnalysisResults,
1333 root: &Path,
1334) -> FxHashSet<String> {
1335 let mut keys = FxHashSet::default();
1336 for item in &results.unused_files {
1337 keys.insert(format!(
1338 "unused-file:{}",
1339 relative_key_path(&item.file.path, root)
1340 ));
1341 }
1342 for item in &results.unused_exports {
1343 keys.insert(format!(
1344 "unused-export:{}:{}",
1345 relative_key_path(&item.export.path, root),
1346 item.export.export_name
1347 ));
1348 }
1349 for item in &results.unused_types {
1350 keys.insert(format!(
1351 "unused-type:{}:{}",
1352 relative_key_path(&item.export.path, root),
1353 item.export.export_name
1354 ));
1355 }
1356 for item in &results.private_type_leaks {
1357 keys.insert(format!(
1358 "private-type-leak:{}:{}:{}",
1359 relative_key_path(&item.leak.path, root),
1360 item.leak.export_name,
1361 item.leak.type_name
1362 ));
1363 }
1364 for item in results
1365 .unused_dependencies
1366 .iter()
1367 .map(|f| &f.dep)
1368 .chain(results.unused_dev_dependencies.iter().map(|f| &f.dep))
1369 .chain(results.unused_optional_dependencies.iter().map(|f| &f.dep))
1370 {
1371 keys.insert(unused_dependency_key(item, root));
1372 }
1373 for item in &results.unused_enum_members {
1374 keys.insert(unused_member_key("unused-enum-member", &item.member, root));
1375 }
1376 for item in &results.unused_class_members {
1377 keys.insert(unused_member_key("unused-class-member", &item.member, root));
1378 }
1379 for item in &results.unresolved_imports {
1380 keys.insert(format!(
1381 "unresolved-import:{}:{}",
1382 relative_key_path(&item.import.path, root),
1383 item.import.specifier
1384 ));
1385 }
1386 for item in results.unlisted_dependencies.iter().map(|f| &f.dep) {
1387 keys.insert(unlisted_dependency_key(item, root));
1388 }
1389 for item in &results.duplicate_exports {
1390 let mut locations: Vec<String> = item
1391 .export
1392 .locations
1393 .iter()
1394 .map(|loc| relative_key_path(&loc.path, root))
1395 .collect();
1396 locations.sort();
1397 locations.dedup();
1398 keys.insert(format!(
1399 "duplicate-export:{}:{}",
1400 item.export.export_name,
1401 locations.join("|")
1402 ));
1403 }
1404 for item in &results.type_only_dependencies {
1405 keys.insert(format!(
1406 "type-only-dependency:{}:{}",
1407 relative_key_path(&item.dep.path, root),
1408 item.dep.package_name
1409 ));
1410 }
1411 for item in &results.test_only_dependencies {
1412 keys.insert(format!(
1413 "test-only-dependency:{}:{}",
1414 relative_key_path(&item.dep.path, root),
1415 item.dep.package_name
1416 ));
1417 }
1418 for item in &results.circular_dependencies {
1419 let mut files: Vec<String> = item
1420 .cycle
1421 .files
1422 .iter()
1423 .map(|path| relative_key_path(path, root))
1424 .collect();
1425 files.sort();
1426 keys.insert(format!("circular-dependency:{}", files.join("|")));
1427 }
1428 for item in &results.boundary_violations {
1429 keys.insert(format!(
1430 "boundary-violation:{}:{}:{}",
1431 relative_key_path(&item.violation.from_path, root),
1432 relative_key_path(&item.violation.to_path, root),
1433 item.violation.import_specifier
1434 ));
1435 }
1436 for item in &results.stale_suppressions {
1437 keys.insert(format!(
1438 "stale-suppression:{}:{}",
1439 relative_key_path(&item.path, root),
1440 item.description()
1441 ));
1442 }
1443 for item in &results.unresolved_catalog_references {
1444 keys.insert(format!(
1445 "unresolved-catalog-reference:{}:{}:{}:{}",
1446 relative_key_path(&item.reference.path, root),
1447 item.reference.line,
1448 item.reference.catalog_name,
1449 item.reference.entry_name
1450 ));
1451 }
1452 for item in &results.unused_catalog_entries {
1453 keys.insert(unused_catalog_entry_key(&item.entry, root));
1454 }
1455 for item in &results.empty_catalog_groups {
1456 keys.insert(empty_catalog_group_key(&item.group, root));
1457 }
1458 for item in &results.unused_dependency_overrides {
1459 keys.insert(format!(
1460 "unused-dependency-override:{}:{}:{}",
1461 relative_key_path(&item.entry.path, root),
1462 item.entry.line,
1463 item.entry.raw_key
1464 ));
1465 }
1466 for item in &results.misconfigured_dependency_overrides {
1467 keys.insert(format!(
1468 "misconfigured-dependency-override:{}:{}:{}",
1469 relative_key_path(&item.entry.path, root),
1470 item.entry.line,
1471 item.entry.raw_key
1472 ));
1473 }
1474 keys
1475}
1476
1477fn retain_introduced_dead_code(
1478 results: &mut fallow_core::results::AnalysisResults,
1479 root: &Path,
1480 base: Option<&FxHashSet<String>>,
1481) {
1482 let Some(base) = base else {
1483 return;
1484 };
1485 results.unused_files.retain(|item| {
1486 !base.contains(&format!(
1487 "unused-file:{}",
1488 relative_key_path(&item.file.path, root)
1489 ))
1490 });
1491 results.unused_exports.retain(|item| {
1492 !base.contains(&format!(
1493 "unused-export:{}:{}",
1494 relative_key_path(&item.export.path, root),
1495 item.export.export_name
1496 ))
1497 });
1498 results.unused_types.retain(|item| {
1499 !base.contains(&format!(
1500 "unused-type:{}:{}",
1501 relative_key_path(&item.export.path, root),
1502 item.export.export_name
1503 ))
1504 });
1505 let introduced = dead_code_keys(results, root)
1508 .into_iter()
1509 .filter(|key| !base.contains(key))
1510 .collect::<FxHashSet<_>>();
1511 let keep = |key: String| introduced.contains(&key);
1512 results.private_type_leaks.retain(|item| {
1513 keep(format!(
1514 "private-type-leak:{}:{}:{}",
1515 relative_key_path(&item.leak.path, root),
1516 item.leak.export_name,
1517 item.leak.type_name
1518 ))
1519 });
1520 results
1521 .unused_dependencies
1522 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1523 results
1524 .unused_dev_dependencies
1525 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1526 results
1527 .unused_optional_dependencies
1528 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1529 results
1530 .unused_enum_members
1531 .retain(|item| keep(unused_member_key("unused-enum-member", &item.member, root)));
1532 results
1533 .unused_class_members
1534 .retain(|item| keep(unused_member_key("unused-class-member", &item.member, root)));
1535 results.unresolved_imports.retain(|item| {
1536 keep(format!(
1537 "unresolved-import:{}:{}",
1538 relative_key_path(&item.import.path, root),
1539 item.import.specifier
1540 ))
1541 });
1542 results
1543 .unlisted_dependencies
1544 .retain(|item| keep(unlisted_dependency_key(&item.dep, root)));
1545 results.duplicate_exports.retain(|item| {
1546 let mut locations: Vec<String> = item
1547 .export
1548 .locations
1549 .iter()
1550 .map(|loc| relative_key_path(&loc.path, root))
1551 .collect();
1552 locations.sort();
1553 locations.dedup();
1554 keep(format!(
1555 "duplicate-export:{}:{}",
1556 item.export.export_name,
1557 locations.join("|")
1558 ))
1559 });
1560 results.type_only_dependencies.retain(|item| {
1561 keep(format!(
1562 "type-only-dependency:{}:{}",
1563 relative_key_path(&item.dep.path, root),
1564 item.dep.package_name
1565 ))
1566 });
1567 results.test_only_dependencies.retain(|item| {
1568 keep(format!(
1569 "test-only-dependency:{}:{}",
1570 relative_key_path(&item.dep.path, root),
1571 item.dep.package_name
1572 ))
1573 });
1574 results.circular_dependencies.retain(|item| {
1575 let mut files: Vec<String> = item
1576 .cycle
1577 .files
1578 .iter()
1579 .map(|path| relative_key_path(path, root))
1580 .collect();
1581 files.sort();
1582 keep(format!("circular-dependency:{}", files.join("|")))
1583 });
1584 results.boundary_violations.retain(|item| {
1585 keep(format!(
1586 "boundary-violation:{}:{}:{}",
1587 relative_key_path(&item.violation.from_path, root),
1588 relative_key_path(&item.violation.to_path, root),
1589 item.violation.import_specifier
1590 ))
1591 });
1592 results.stale_suppressions.retain(|item| {
1593 keep(format!(
1594 "stale-suppression:{}:{}",
1595 relative_key_path(&item.path, root),
1596 item.description()
1597 ))
1598 });
1599 results.unresolved_catalog_references.retain(|item| {
1600 keep(format!(
1601 "unresolved-catalog-reference:{}:{}:{}:{}",
1602 relative_key_path(&item.reference.path, root),
1603 item.reference.line,
1604 item.reference.catalog_name,
1605 item.reference.entry_name
1606 ))
1607 });
1608 results
1609 .unused_catalog_entries
1610 .retain(|item| keep(unused_catalog_entry_key(&item.entry, root)));
1611 results
1612 .empty_catalog_groups
1613 .retain(|item| keep(empty_catalog_group_key(&item.group, root)));
1614 results.unused_dependency_overrides.retain(|item| {
1615 keep(format!(
1616 "unused-dependency-override:{}:{}:{}",
1617 relative_key_path(&item.entry.path, root),
1618 item.entry.line,
1619 item.entry.raw_key
1620 ))
1621 });
1622 results.misconfigured_dependency_overrides.retain(|item| {
1623 keep(format!(
1624 "misconfigured-dependency-override:{}:{}:{}",
1625 relative_key_path(&item.entry.path, root),
1626 item.entry.line,
1627 item.entry.raw_key
1628 ))
1629 });
1630}
1631
1632fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
1633 !base.contains(key)
1634}
1635
1636fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
1637where
1638 I: IntoIterator<Item = bool>,
1639{
1640 let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
1641 return;
1642 };
1643 for (item, introduced) in items.iter_mut().zip(introduced) {
1644 if let serde_json::Value::Object(map) = item {
1645 map.insert("introduced".to_string(), serde_json::json!(introduced));
1646 }
1647 }
1648}
1649
1650#[expect(
1651 clippy::too_many_lines,
1652 reason = "keeps audit attribution keys adjacent to the JSON arrays they annotate"
1653)]
1654fn annotate_dead_code_json(
1655 json: &mut serde_json::Value,
1656 results: &fallow_core::results::AnalysisResults,
1657 root: &Path,
1658 base: &FxHashSet<String>,
1659) {
1660 annotate_issue_array(
1661 json,
1662 "unused_files",
1663 results.unused_files.iter().map(|item| {
1664 issue_was_introduced(
1665 &format!("unused-file:{}", relative_key_path(&item.file.path, root)),
1666 base,
1667 )
1668 }),
1669 );
1670 annotate_issue_array(
1671 json,
1672 "unused_exports",
1673 results.unused_exports.iter().map(|item| {
1674 issue_was_introduced(
1675 &format!(
1676 "unused-export:{}:{}",
1677 relative_key_path(&item.export.path, root),
1678 item.export.export_name
1679 ),
1680 base,
1681 )
1682 }),
1683 );
1684 annotate_issue_array(
1685 json,
1686 "unused_types",
1687 results.unused_types.iter().map(|item| {
1688 issue_was_introduced(
1689 &format!(
1690 "unused-type:{}:{}",
1691 relative_key_path(&item.export.path, root),
1692 item.export.export_name
1693 ),
1694 base,
1695 )
1696 }),
1697 );
1698 annotate_issue_array(
1699 json,
1700 "private_type_leaks",
1701 results.private_type_leaks.iter().map(|item| {
1702 issue_was_introduced(
1703 &format!(
1704 "private-type-leak:{}:{}:{}",
1705 relative_key_path(&item.leak.path, root),
1706 item.leak.export_name,
1707 item.leak.type_name
1708 ),
1709 base,
1710 )
1711 }),
1712 );
1713 annotate_issue_array(
1714 json,
1715 "unused_dependencies",
1716 results
1717 .unused_dependencies
1718 .iter()
1719 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
1720 );
1721 annotate_issue_array(
1722 json,
1723 "unused_dev_dependencies",
1724 results
1725 .unused_dev_dependencies
1726 .iter()
1727 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
1728 );
1729 annotate_issue_array(
1730 json,
1731 "unused_optional_dependencies",
1732 results
1733 .unused_optional_dependencies
1734 .iter()
1735 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
1736 );
1737 annotate_issue_array(
1738 json,
1739 "unused_enum_members",
1740 results.unused_enum_members.iter().map(|item| {
1741 issue_was_introduced(
1742 &unused_member_key("unused-enum-member", &item.member, root),
1743 base,
1744 )
1745 }),
1746 );
1747 annotate_issue_array(
1748 json,
1749 "unused_class_members",
1750 results.unused_class_members.iter().map(|item| {
1751 issue_was_introduced(
1752 &unused_member_key("unused-class-member", &item.member, root),
1753 base,
1754 )
1755 }),
1756 );
1757 annotate_issue_array(
1758 json,
1759 "unresolved_imports",
1760 results.unresolved_imports.iter().map(|item| {
1761 issue_was_introduced(
1762 &format!(
1763 "unresolved-import:{}:{}",
1764 relative_key_path(&item.import.path, root),
1765 item.import.specifier
1766 ),
1767 base,
1768 )
1769 }),
1770 );
1771 annotate_issue_array(
1772 json,
1773 "unlisted_dependencies",
1774 results
1775 .unlisted_dependencies
1776 .iter()
1777 .map(|item| issue_was_introduced(&unlisted_dependency_key(&item.dep, root), base)),
1778 );
1779 annotate_issue_array(
1780 json,
1781 "duplicate_exports",
1782 results.duplicate_exports.iter().map(|item| {
1783 let mut locations: Vec<String> = item
1784 .export
1785 .locations
1786 .iter()
1787 .map(|loc| relative_key_path(&loc.path, root))
1788 .collect();
1789 locations.sort();
1790 locations.dedup();
1791 issue_was_introduced(
1792 &format!(
1793 "duplicate-export:{}:{}",
1794 item.export.export_name,
1795 locations.join("|")
1796 ),
1797 base,
1798 )
1799 }),
1800 );
1801 annotate_issue_array(
1802 json,
1803 "type_only_dependencies",
1804 results.type_only_dependencies.iter().map(|item| {
1805 issue_was_introduced(
1806 &format!(
1807 "type-only-dependency:{}:{}",
1808 relative_key_path(&item.dep.path, root),
1809 item.dep.package_name
1810 ),
1811 base,
1812 )
1813 }),
1814 );
1815 annotate_issue_array(
1816 json,
1817 "test_only_dependencies",
1818 results.test_only_dependencies.iter().map(|item| {
1819 issue_was_introduced(
1820 &format!(
1821 "test-only-dependency:{}:{}",
1822 relative_key_path(&item.dep.path, root),
1823 item.dep.package_name
1824 ),
1825 base,
1826 )
1827 }),
1828 );
1829 annotate_issue_array(
1830 json,
1831 "circular_dependencies",
1832 results.circular_dependencies.iter().map(|item| {
1833 let mut files: Vec<String> = item
1834 .cycle
1835 .files
1836 .iter()
1837 .map(|path| relative_key_path(path, root))
1838 .collect();
1839 files.sort();
1840 issue_was_introduced(&format!("circular-dependency:{}", files.join("|")), base)
1841 }),
1842 );
1843 annotate_issue_array(
1844 json,
1845 "boundary_violations",
1846 results.boundary_violations.iter().map(|item| {
1847 issue_was_introduced(
1848 &format!(
1849 "boundary-violation:{}:{}:{}",
1850 relative_key_path(&item.violation.from_path, root),
1851 relative_key_path(&item.violation.to_path, root),
1852 item.violation.import_specifier
1853 ),
1854 base,
1855 )
1856 }),
1857 );
1858 annotate_issue_array(
1859 json,
1860 "stale_suppressions",
1861 results.stale_suppressions.iter().map(|item| {
1862 issue_was_introduced(
1863 &format!(
1864 "stale-suppression:{}:{}",
1865 relative_key_path(&item.path, root),
1866 item.description()
1867 ),
1868 base,
1869 )
1870 }),
1871 );
1872 annotate_issue_array(
1873 json,
1874 "unresolved_catalog_references",
1875 results.unresolved_catalog_references.iter().map(|item| {
1876 issue_was_introduced(
1877 &format!(
1878 "unresolved-catalog-reference:{}:{}:{}:{}",
1879 relative_key_path(&item.reference.path, root),
1880 item.reference.line,
1881 item.reference.catalog_name,
1882 item.reference.entry_name
1883 ),
1884 base,
1885 )
1886 }),
1887 );
1888 annotate_issue_array(
1889 json,
1890 "unused_catalog_entries",
1891 results
1892 .unused_catalog_entries
1893 .iter()
1894 .map(|item| issue_was_introduced(&unused_catalog_entry_key(&item.entry, root), base)),
1895 );
1896 annotate_issue_array(
1897 json,
1898 "empty_catalog_groups",
1899 results
1900 .empty_catalog_groups
1901 .iter()
1902 .map(|item| issue_was_introduced(&empty_catalog_group_key(&item.group, root), base)),
1903 );
1904 annotate_issue_array(
1905 json,
1906 "unused_dependency_overrides",
1907 results.unused_dependency_overrides.iter().map(|item| {
1908 issue_was_introduced(
1909 &format!(
1910 "unused-dependency-override:{}:{}:{}",
1911 relative_key_path(&item.entry.path, root),
1912 item.entry.line,
1913 item.entry.raw_key
1914 ),
1915 base,
1916 )
1917 }),
1918 );
1919 annotate_issue_array(
1920 json,
1921 "misconfigured_dependency_overrides",
1922 results
1923 .misconfigured_dependency_overrides
1924 .iter()
1925 .map(|item| {
1926 issue_was_introduced(
1927 &format!(
1928 "misconfigured-dependency-override:{}:{}:{}",
1929 relative_key_path(&item.entry.path, root),
1930 item.entry.line,
1931 item.entry.raw_key
1932 ),
1933 base,
1934 )
1935 }),
1936 );
1937}
1938
1939fn annotate_health_json(
1940 json: &mut serde_json::Value,
1941 report: &crate::health_types::HealthReport,
1942 root: &Path,
1943 base: &FxHashSet<String>,
1944) {
1945 let Some(items) = json
1946 .get_mut("findings")
1947 .and_then(serde_json::Value::as_array_mut)
1948 else {
1949 return;
1950 };
1951 for (item, finding) in items.iter_mut().zip(&report.findings) {
1952 if let serde_json::Value::Object(map) = item {
1953 map.insert(
1954 "introduced".to_string(),
1955 serde_json::json!(issue_was_introduced(
1956 &health_finding_key(finding, root),
1957 base
1958 )),
1959 );
1960 }
1961 }
1962}
1963
1964fn annotate_dupes_json(
1965 json: &mut serde_json::Value,
1966 report: &fallow_core::duplicates::DuplicationReport,
1967 root: &Path,
1968 base: &FxHashSet<String>,
1969) {
1970 let Some(items) = json
1971 .get_mut("clone_groups")
1972 .and_then(serde_json::Value::as_array_mut)
1973 else {
1974 return;
1975 };
1976 for (item, group) in items.iter_mut().zip(&report.clone_groups) {
1977 if let serde_json::Value::Object(map) = item {
1978 map.insert(
1979 "introduced".to_string(),
1980 serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
1981 );
1982 }
1983 }
1984}
1985
1986fn health_keys(report: &crate::health_types::HealthReport, root: &Path) -> FxHashSet<String> {
1987 report
1988 .findings
1989 .iter()
1990 .map(|finding| health_finding_key(finding, root))
1991 .collect()
1992}
1993
1994fn health_finding_key(finding: &crate::health_types::ComplexityViolation, root: &Path) -> String {
1995 format!(
1996 "complexity:{}:{}:{:?}",
1997 relative_key_path(&finding.path, root),
1998 finding.name,
1999 finding.exceeded
2000 )
2001}
2002
2003fn dupes_keys(
2004 report: &fallow_core::duplicates::DuplicationReport,
2005 root: &Path,
2006) -> FxHashSet<String> {
2007 report
2008 .clone_groups
2009 .iter()
2010 .map(|group| dupe_group_key(group, root))
2011 .collect()
2012}
2013
2014fn dupe_group_key(group: &fallow_core::duplicates::CloneGroup, root: &Path) -> String {
2015 let mut files: Vec<String> = group
2016 .instances
2017 .iter()
2018 .map(|instance| relative_key_path(&instance.file, root))
2019 .collect();
2020 files.sort();
2021 files.dedup();
2022 let mut hasher = DefaultHasher::new();
2023 for instance in &group.instances {
2024 instance.fragment.hash(&mut hasher);
2025 }
2026 format!(
2027 "dupe:{}:{}:{}:{:x}",
2028 files.join("|"),
2029 group.token_count,
2030 group.line_count,
2031 hasher.finish()
2032 )
2033}
2034
2035struct HeadAnalyses {
2042 check: Option<CheckResult>,
2043 dupes: Option<DupesResult>,
2044 health: Option<HealthResult>,
2045}
2046
2047fn run_audit_head_analyses(
2054 opts: &AuditOptions<'_>,
2055 changed_since: Option<&str>,
2056 changed_files: &FxHashSet<PathBuf>,
2057) -> Result<HeadAnalyses, ExitCode> {
2058 let check_production = opts.production_dead_code.unwrap_or(opts.production);
2059 let health_production = opts.production_health.unwrap_or(opts.production);
2060 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
2061 let share_dead_code_parse_with_health = check_production == health_production;
2062 let share_dead_code_files_with_dupes =
2063 share_dead_code_parse_with_health && check_production == dupes_production;
2064
2065 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
2066 let dupes_files = if share_dead_code_files_with_dupes {
2067 check
2068 .as_ref()
2069 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
2070 } else {
2071 None
2072 };
2073 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
2074 let shared_parse = if share_dead_code_parse_with_health {
2075 check.as_mut().and_then(|r| r.shared_parse.take())
2076 } else {
2077 None
2078 };
2079 let health = run_audit_health(opts, changed_since, shared_parse)?;
2080 Ok(HeadAnalyses {
2081 check,
2082 dupes,
2083 health,
2084 })
2085}
2086
2087pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
2089 let start = Instant::now();
2090
2091 let base_ref = resolve_base_ref(opts)?;
2092
2093 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
2095 return Err(emit_error(
2096 &format!(
2097 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
2098 ),
2099 2,
2100 opts.output,
2101 ));
2102 };
2103 let changed_files_count = changed_files.len();
2104
2105 if changed_files.is_empty() {
2106 return Ok(empty_audit_result(base_ref, opts, start.elapsed()));
2107 }
2108
2109 let changed_since = Some(base_ref.as_str());
2110
2111 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
2119 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
2120 let base_cache_key = if needs_real_base_snapshot {
2121 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
2122 } else {
2123 None
2124 };
2125 let cached_base_snapshot = base_cache_key
2126 .as_ref()
2127 .and_then(|key| load_cached_base_snapshot(opts, key));
2128
2129 let (head_res, base_res) = if needs_real_base_snapshot && cached_base_snapshot.is_none() {
2130 let base_sha = base_cache_key.as_ref().map(|key| key.base_sha.as_str());
2131 let (h, b) = rayon::join(
2132 || run_audit_head_analyses(opts, changed_since, &changed_files),
2133 || compute_base_snapshot(opts, &base_ref, &changed_files, base_sha),
2134 );
2135 (h, Some(b))
2136 } else {
2137 (
2138 run_audit_head_analyses(opts, changed_since, &changed_files),
2139 None,
2140 )
2141 };
2142
2143 let head = head_res?;
2144 let mut check_result = head.check;
2145 let dupes_result = head.dupes;
2146 let health_result = head.health;
2147
2148 let (base_snapshot, base_snapshot_skipped) = if matches!(opts.gate, AuditGate::NewOnly) {
2149 if let Some(snapshot) = cached_base_snapshot {
2150 (Some(snapshot), false)
2151 } else if let Some(base_res) = base_res {
2152 let snapshot = base_res?;
2153 if let Some(ref key) = base_cache_key {
2154 save_cached_base_snapshot(opts, key, &snapshot);
2155 }
2156 (Some(snapshot), false)
2157 } else {
2158 (
2159 Some(current_keys_as_base_keys(
2160 check_result.as_ref(),
2161 dupes_result.as_ref(),
2162 health_result.as_ref(),
2163 )),
2164 true,
2165 )
2166 }
2167 } else {
2168 (None, false)
2169 };
2170 if let Some(ref mut check) = check_result {
2172 check.shared_parse = None;
2173 }
2174 let attribution = compute_audit_attribution(
2175 check_result.as_ref(),
2176 dupes_result.as_ref(),
2177 health_result.as_ref(),
2178 base_snapshot.as_ref(),
2179 opts.gate,
2180 );
2181 let verdict = if matches!(opts.gate, AuditGate::NewOnly) {
2182 compute_introduced_verdict(
2183 check_result.as_ref(),
2184 dupes_result.as_ref(),
2185 health_result.as_ref(),
2186 base_snapshot.as_ref(),
2187 )
2188 } else {
2189 compute_verdict(
2190 check_result.as_ref(),
2191 dupes_result.as_ref(),
2192 health_result.as_ref(),
2193 )
2194 };
2195 let summary = build_summary(
2196 check_result.as_ref(),
2197 dupes_result.as_ref(),
2198 health_result.as_ref(),
2199 );
2200
2201 Ok(AuditResult {
2202 verdict,
2203 summary,
2204 attribution,
2205 base_snapshot,
2206 base_snapshot_skipped,
2207 changed_files_count,
2208 base_ref,
2209 head_sha: get_head_sha(opts.root),
2210 output: opts.output,
2211 performance: opts.performance,
2212 check: check_result,
2213 dupes: dupes_result,
2214 health: health_result,
2215 elapsed: start.elapsed(),
2216 })
2217}
2218
2219fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<String, ExitCode> {
2221 if let Some(ref_str) = opts.changed_since {
2222 return Ok(ref_str.to_string());
2223 }
2224 let Some(branch) = auto_detect_base_branch(opts.root) else {
2225 return Err(emit_error(
2226 "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
2227 2,
2228 opts.output,
2229 ));
2230 };
2231 if let Err(e) = crate::validate::validate_git_ref(&branch) {
2233 return Err(emit_error(
2234 &format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"),
2235 2,
2236 opts.output,
2237 ));
2238 }
2239 Ok(branch)
2240}
2241
2242fn empty_audit_result(base_ref: String, opts: &AuditOptions<'_>, elapsed: Duration) -> AuditResult {
2244 AuditResult {
2245 verdict: AuditVerdict::Pass,
2246 summary: AuditSummary {
2247 dead_code_issues: 0,
2248 dead_code_has_errors: false,
2249 complexity_findings: 0,
2250 max_cyclomatic: None,
2251 duplication_clone_groups: 0,
2252 },
2253 attribution: AuditAttribution {
2254 gate: opts.gate,
2255 ..AuditAttribution::default()
2256 },
2257 base_snapshot: None,
2258 base_snapshot_skipped: false,
2259 changed_files_count: 0,
2260 base_ref,
2261 head_sha: get_head_sha(opts.root),
2262 output: opts.output,
2263 performance: opts.performance,
2264 check: None,
2265 dupes: None,
2266 health: None,
2267 elapsed,
2268 }
2269}
2270
2271fn run_audit_check<'a>(
2273 opts: &'a AuditOptions<'a>,
2274 changed_since: Option<&'a str>,
2275 retain_modules_for_health: bool,
2276) -> Result<Option<CheckResult>, ExitCode> {
2277 let filters = IssueFilters::default();
2278 let trace_opts = TraceOptions {
2279 trace_export: None,
2280 trace_file: None,
2281 trace_dependency: None,
2282 performance: opts.performance,
2283 };
2284 match crate::check::execute_check(&CheckOptions {
2285 root: opts.root,
2286 config_path: opts.config_path,
2287 output: opts.output,
2288 no_cache: opts.no_cache,
2289 threads: opts.threads,
2290 quiet: opts.quiet,
2291 fail_on_issues: false,
2292 filters: &filters,
2293 changed_since,
2294 baseline: opts.dead_code_baseline,
2295 save_baseline: None,
2296 sarif_file: None,
2297 production: opts.production_dead_code.unwrap_or(opts.production),
2298 production_override: opts.production_dead_code,
2299 workspace: opts.workspace,
2300 changed_workspaces: opts.changed_workspaces,
2301 group_by: opts.group_by,
2302 include_dupes: false,
2303 trace_opts: &trace_opts,
2304 explain: opts.explain,
2305 top: None,
2306 file: &[],
2307 include_entry_exports: opts.include_entry_exports,
2308 summary: false,
2309 regression_opts: crate::regression::RegressionOpts {
2310 fail_on_regression: false,
2311 tolerance: crate::regression::Tolerance::Absolute(0),
2312 regression_baseline_file: None,
2313 save_target: crate::regression::SaveRegressionTarget::None,
2314 scoped: true,
2315 quiet: opts.quiet,
2316 },
2317 retain_modules_for_health,
2318 defer_performance: false,
2319 }) {
2320 Ok(r) => Ok(Some(r)),
2321 Err(code) => Err(code),
2322 }
2323}
2324
2325fn run_audit_dupes<'a>(
2331 opts: &'a AuditOptions<'a>,
2332 changed_since: Option<&'a str>,
2333 changed_files: Option<&'a FxHashSet<PathBuf>>,
2334 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2335) -> Result<Option<DupesResult>, ExitCode> {
2336 let dupes_cfg = match crate::load_config_for_analysis(
2337 opts.root,
2338 opts.config_path,
2339 opts.output,
2340 opts.no_cache,
2341 opts.threads,
2342 opts.production_dupes
2343 .or_else(|| opts.production.then_some(true)),
2344 opts.quiet,
2345 fallow_config::ProductionAnalysis::Dupes,
2346 ) {
2347 Ok(c) => c.duplicates,
2348 Err(code) => return Err(code),
2349 };
2350 let dupes_opts = DupesOptions {
2351 root: opts.root,
2352 config_path: opts.config_path,
2353 output: opts.output,
2354 no_cache: opts.no_cache,
2355 threads: opts.threads,
2356 quiet: opts.quiet,
2357 mode: Some(DupesMode::from(dupes_cfg.mode)),
2361 min_tokens: Some(dupes_cfg.min_tokens),
2362 min_lines: Some(dupes_cfg.min_lines),
2363 min_occurrences: Some(dupes_cfg.min_occurrences),
2364 threshold: Some(dupes_cfg.threshold),
2365 skip_local: dupes_cfg.skip_local,
2366 cross_language: dupes_cfg.cross_language,
2367 ignore_imports: dupes_cfg.ignore_imports,
2368 top: None,
2369 baseline_path: opts.dupes_baseline,
2370 save_baseline_path: None,
2371 production: opts.production_dupes.unwrap_or(opts.production),
2372 production_override: opts.production_dupes,
2373 trace: None,
2374 changed_since,
2375 changed_files,
2376 workspace: opts.workspace,
2377 changed_workspaces: opts.changed_workspaces,
2378 explain: opts.explain,
2379 explain_skipped: opts.explain_skipped,
2380 summary: false,
2381 group_by: opts.group_by,
2382 performance: false,
2385 };
2386 let dupes_run = if let Some(files) = pre_discovered {
2387 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2388 } else {
2389 crate::dupes::execute_dupes(&dupes_opts)
2390 };
2391 match dupes_run {
2392 Ok(r) => Ok(Some(r)),
2393 Err(code) => Err(code),
2394 }
2395}
2396
2397fn run_audit_health<'a>(
2399 opts: &'a AuditOptions<'a>,
2400 changed_since: Option<&'a str>,
2401 shared_parse: Option<crate::health::SharedParseData>,
2402) -> Result<Option<HealthResult>, ExitCode> {
2403 let runtime_coverage = match opts.runtime_coverage {
2408 Some(path) => match crate::health::coverage::prepare_options(
2409 path,
2410 opts.min_invocations_hot,
2411 None,
2412 None,
2413 opts.output,
2414 ) {
2415 Ok(options) => Some(options),
2416 Err(code) => return Err(code),
2417 },
2418 None => None,
2419 };
2420
2421 let health_opts = HealthOptions {
2422 root: opts.root,
2423 config_path: opts.config_path,
2424 output: opts.output,
2425 no_cache: opts.no_cache,
2426 threads: opts.threads,
2427 quiet: opts.quiet,
2428 max_cyclomatic: None,
2429 max_cognitive: None,
2430 max_crap: opts.max_crap,
2431 top: None,
2432 sort: SortBy::Cyclomatic,
2433 production: opts.production_health.unwrap_or(opts.production),
2434 production_override: opts.production_health,
2435 changed_since,
2436 workspace: opts.workspace,
2437 changed_workspaces: opts.changed_workspaces,
2438 baseline: opts.health_baseline,
2439 save_baseline: None,
2440 complexity: true,
2441 file_scores: false,
2442 coverage_gaps: false,
2443 config_activates_coverage_gaps: false,
2444 hotspots: false,
2445 ownership: false,
2446 ownership_emails: None,
2447 targets: false,
2448 force_full: false,
2449 score_only_output: false,
2450 enforce_coverage_gap_gate: false,
2451 effort: None,
2452 score: false,
2453 min_score: None,
2454 since: None,
2455 min_commits: None,
2456 explain: opts.explain,
2457 summary: false,
2458 save_snapshot: None,
2459 trend: false,
2460 group_by: opts.group_by,
2461 coverage: opts.coverage,
2462 coverage_root: opts.coverage_root,
2463 performance: opts.performance,
2464 min_severity: None,
2465 runtime_coverage,
2466 };
2467 let health_run = if let Some(shared) = shared_parse {
2468 crate::health::execute_health_with_shared_parse(&health_opts, shared)
2469 } else {
2470 crate::health::execute_health(&health_opts)
2471 };
2472 match health_run {
2473 Ok(r) => Ok(Some(r)),
2474 Err(code) => Err(code),
2475 }
2476}
2477
2478#[must_use]
2482pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
2483 let output = result.output;
2484
2485 let format_exit = match output {
2486 OutputFormat::Json => print_audit_json(result),
2487 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
2488 print_audit_human(result, quiet, explain, output);
2489 ExitCode::SUCCESS
2490 }
2491 OutputFormat::Sarif => print_audit_sarif(result),
2492 OutputFormat::CodeClimate => print_audit_codeclimate(result),
2493 OutputFormat::PrCommentGithub => {
2494 let value = build_audit_codeclimate(result);
2495 report::ci::pr_comment::print_pr_comment(
2496 "audit",
2497 report::ci::pr_comment::Provider::Github,
2498 &value,
2499 )
2500 }
2501 OutputFormat::PrCommentGitlab => {
2502 let value = build_audit_codeclimate(result);
2503 report::ci::pr_comment::print_pr_comment(
2504 "audit",
2505 report::ci::pr_comment::Provider::Gitlab,
2506 &value,
2507 )
2508 }
2509 OutputFormat::ReviewGithub => {
2510 let value = build_audit_codeclimate(result);
2511 report::ci::review::print_review_envelope(
2512 "audit",
2513 report::ci::pr_comment::Provider::Github,
2514 &value,
2515 )
2516 }
2517 OutputFormat::ReviewGitlab => {
2518 let value = build_audit_codeclimate(result);
2519 report::ci::review::print_review_envelope(
2520 "audit",
2521 report::ci::pr_comment::Provider::Gitlab,
2522 &value,
2523 )
2524 }
2525 OutputFormat::Badge => {
2526 eprintln!("Error: badge format is not supported for the audit command");
2527 return ExitCode::from(2);
2528 }
2529 };
2530
2531 if format_exit != ExitCode::SUCCESS {
2532 return format_exit;
2533 }
2534
2535 match result.verdict {
2536 AuditVerdict::Fail => ExitCode::from(1),
2537 AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
2538 }
2539}
2540
2541fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
2544 let show_headers = matches!(output, OutputFormat::Human) && !quiet;
2545
2546 if !quiet {
2548 let scope = format_scope_line(result);
2549 eprintln!();
2550 eprintln!("{scope}");
2551 }
2552
2553 let has_check_issues = result.summary.dead_code_issues > 0;
2554 let has_health_findings = result.summary.complexity_findings > 0;
2555 let has_dupe_groups = result.summary.duplication_clone_groups > 0;
2556 let has_any_findings = has_check_issues || has_health_findings || has_dupe_groups;
2557
2558 if has_any_findings {
2560 if show_headers && std::io::stdout().is_terminal() {
2561 println!(
2562 "{}",
2563 "Tip: run `fallow explain <issue-type>` for any finding below.".dimmed()
2564 );
2565 println!();
2566 }
2567
2568 if result.verdict != AuditVerdict::Fail && !quiet {
2570 print_audit_vital_signs(result);
2571 }
2572
2573 if has_check_issues && let Some(ref check) = result.check {
2574 if show_headers {
2575 eprintln!();
2576 eprintln!("── Dead Code ──────────────────────────────────────");
2577 }
2578 crate::check::print_check_result(
2579 check,
2580 crate::check::PrintCheckOptions {
2581 quiet,
2582 explain,
2583 regression_json: false,
2584 group_by: None,
2585 top: None,
2586 summary: false,
2587 show_explain_tip: false,
2588 },
2589 );
2590 }
2591
2592 if has_dupe_groups && let Some(ref dupes) = result.dupes {
2593 if show_headers {
2594 eprintln!();
2595 eprintln!("── Duplication ────────────────────────────────────");
2596 }
2597 crate::dupes::print_dupes_result(dupes, quiet, explain, false, false);
2598 }
2599
2600 if has_health_findings && let Some(ref health) = result.health {
2601 if show_headers {
2602 eprintln!();
2603 eprintln!("── Complexity ─────────────────────────────────────");
2604 }
2605 crate::health::print_health_result(health, quiet, explain, None, None, false, false);
2606 }
2607 }
2608
2609 if !has_dupe_groups && let Some(ref dupes) = result.dupes {
2610 crate::dupes::print_default_ignore_note(dupes, quiet);
2611 crate::dupes::print_min_occurrences_note(dupes, quiet);
2612 }
2613
2614 if !quiet {
2616 print_audit_status_line(result);
2617 }
2618}
2619
2620fn format_scope_line(result: &AuditResult) -> String {
2622 let sha_suffix = result
2623 .head_sha
2624 .as_ref()
2625 .map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
2626 format!(
2627 "Audit scope: {} changed file{} vs {}{}",
2628 result.changed_files_count,
2629 plural(result.changed_files_count),
2630 result.base_ref,
2631 sha_suffix
2632 )
2633}
2634
2635fn print_audit_vital_signs(result: &AuditResult) {
2637 let mut parts = Vec::new();
2638 parts.push(format!("dead code {}", result.summary.dead_code_issues));
2639 if let Some(max) = result.summary.max_cyclomatic {
2640 parts.push(format!(
2641 "complexity {} (warn, max cyclomatic: {max})",
2642 result.summary.complexity_findings
2643 ));
2644 } else {
2645 parts.push(format!("complexity {}", result.summary.complexity_findings));
2646 }
2647 parts.push(format!(
2648 "duplication {}",
2649 result.summary.duplication_clone_groups
2650 ));
2651
2652 let line = parts.join(" \u{00b7} ");
2653 println!(
2654 "{} {} {}",
2655 "\u{25a0}".dimmed(),
2656 "Metrics:".dimmed(),
2657 line.dimmed()
2658 );
2659}
2660
2661fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
2663 let mut parts = Vec::new();
2664 if summary.dead_code_issues > 0 {
2665 let n = summary.dead_code_issues;
2666 parts.push(format!("dead code: {n} issue{}", plural(n)));
2667 }
2668 if summary.complexity_findings > 0 {
2669 let n = summary.complexity_findings;
2670 parts.push(format!("complexity: {n} finding{}", plural(n)));
2671 }
2672 if summary.duplication_clone_groups > 0 {
2673 let n = summary.duplication_clone_groups;
2674 parts.push(format!("duplication: {n} clone group{}", plural(n)));
2675 }
2676 parts
2677}
2678
2679fn print_audit_status_line(result: &AuditResult) {
2681 let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
2682 let n = result.changed_files_count;
2683 let files_str = format!("{n} changed file{}", plural(n));
2684
2685 match result.verdict {
2686 AuditVerdict::Pass => {
2687 eprintln!(
2688 "{}",
2689 format!("\u{2713} No issues in {files_str} ({elapsed_str})")
2690 .green()
2691 .bold()
2692 );
2693 }
2694 AuditVerdict::Warn => {
2695 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
2696 eprintln!(
2697 "{}",
2698 format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
2699 .green()
2700 .bold()
2701 );
2702 }
2703 AuditVerdict::Fail => {
2704 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
2705 eprintln!(
2706 "{}",
2707 format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
2708 .red()
2709 .bold()
2710 );
2711 }
2712 }
2713
2714 if !matches!(result.attribution.gate, AuditGate::All) {
2715 let inherited = result.attribution.dead_code_inherited
2716 + result.attribution.complexity_inherited
2717 + result.attribution.duplication_inherited;
2718 if inherited > 0 {
2719 eprintln!(
2720 " {}",
2721 format!(
2722 "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
2723 plural(inherited)
2724 )
2725 .dimmed()
2726 );
2727 }
2728 }
2729 if result.performance {
2730 eprintln!(
2731 " {}",
2732 format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
2733 );
2734 }
2735}
2736
2737#[expect(
2740 clippy::cast_possible_truncation,
2741 reason = "elapsed milliseconds won't exceed u64::MAX"
2742)]
2743fn print_audit_json(result: &AuditResult) -> ExitCode {
2744 let mut obj = serde_json::Map::new();
2745 obj.insert(
2746 "schema_version".into(),
2747 serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
2748 );
2749 obj.insert(
2750 "version".into(),
2751 serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
2752 );
2753 obj.insert(
2754 "command".into(),
2755 serde_json::Value::String("audit".to_string()),
2756 );
2757 obj.insert(
2758 "verdict".into(),
2759 serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
2760 );
2761 obj.insert(
2762 "changed_files_count".into(),
2763 serde_json::Value::Number(result.changed_files_count.into()),
2764 );
2765 obj.insert(
2766 "base_ref".into(),
2767 serde_json::Value::String(result.base_ref.clone()),
2768 );
2769 if let Some(ref sha) = result.head_sha {
2770 obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
2771 }
2772 obj.insert(
2773 "elapsed_ms".into(),
2774 serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
2775 );
2776 if result.performance {
2777 obj.insert(
2778 "base_snapshot_skipped".into(),
2779 serde_json::Value::Bool(result.base_snapshot_skipped),
2780 );
2781 }
2782
2783 if let Ok(summary_val) = serde_json::to_value(&result.summary) {
2785 obj.insert("summary".into(), summary_val);
2786 }
2787 if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
2788 obj.insert("attribution".into(), attribution_val);
2789 }
2790
2791 if let Some(ref check) = result.check {
2793 match report::build_json_with_config_fixable(
2794 &check.results,
2795 &check.config.root,
2796 check.elapsed,
2797 check.config_fixable,
2798 ) {
2799 Ok(mut json) => {
2800 if let Some(ref base) = result.base_snapshot {
2801 annotate_dead_code_json(
2802 &mut json,
2803 &check.results,
2804 &check.config.root,
2805 &base.dead_code,
2806 );
2807 }
2808 obj.insert("dead_code".into(), json);
2809 }
2810 Err(e) => {
2811 return emit_error(
2812 &format!("JSON serialization error: {e}"),
2813 2,
2814 OutputFormat::Json,
2815 );
2816 }
2817 }
2818 }
2819
2820 if let Some(ref dupes) = result.dupes {
2821 let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
2822 match serde_json::to_value(&payload) {
2823 Ok(mut json) => {
2824 let root_prefix = format!("{}/", dupes.config.root.display());
2825 report::strip_root_prefix(&mut json, &root_prefix);
2826 if let Some(ref base) = result.base_snapshot {
2827 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
2828 }
2829 obj.insert("duplication".into(), json);
2830 }
2831 Err(e) => {
2832 return emit_error(
2833 &format!("JSON serialization error: {e}"),
2834 2,
2835 OutputFormat::Json,
2836 );
2837 }
2838 }
2839 }
2840
2841 if let Some(ref health) = result.health {
2842 match serde_json::to_value(&health.report) {
2843 Ok(mut json) => {
2844 let root_prefix = format!("{}/", health.config.root.display());
2845 report::strip_root_prefix(&mut json, &root_prefix);
2846 if let Some(ref base) = result.base_snapshot {
2847 annotate_health_json(
2848 &mut json,
2849 &health.report,
2850 &health.config.root,
2851 &base.health,
2852 );
2853 }
2854 obj.insert("complexity".into(), json);
2855 }
2856 Err(e) => {
2857 return emit_error(
2858 &format!("JSON serialization error: {e}"),
2859 2,
2860 OutputFormat::Json,
2861 );
2862 }
2863 }
2864 }
2865
2866 let mut output = serde_json::Value::Object(obj);
2867 report::harmonize_multi_kind_suppress_line_actions(&mut output);
2868 report::emit_json(&output, "audit")
2869}
2870
2871fn print_audit_sarif(result: &AuditResult) -> ExitCode {
2874 let mut all_runs = Vec::new();
2875
2876 if let Some(ref check) = result.check {
2877 let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
2878 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
2879 all_runs.extend(runs.iter().cloned());
2880 }
2881 }
2882
2883 if let Some(ref dupes) = result.dupes
2884 && !dupes.report.clone_groups.is_empty()
2885 {
2886 let run = serde_json::json!({
2887 "tool": {
2888 "driver": {
2889 "name": "fallow",
2890 "version": env!("CARGO_PKG_VERSION"),
2891 "informationUri": "https://github.com/fallow-rs/fallow",
2892 }
2893 },
2894 "automationDetails": { "id": "fallow/audit/dupes" },
2895 "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
2896 serde_json::json!({
2897 "ruleId": "fallow/code-duplication",
2898 "level": "warning",
2899 "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
2900 })
2901 }).collect::<Vec<_>>()
2902 });
2903 all_runs.push(run);
2904 }
2905
2906 if let Some(ref health) = result.health {
2907 let sarif = report::build_health_sarif(&health.report, &health.config.root);
2908 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
2909 all_runs.extend(runs.iter().cloned());
2910 }
2911 }
2912
2913 let combined = serde_json::json!({
2914 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2915 "version": "2.1.0",
2916 "runs": all_runs,
2917 });
2918
2919 report::emit_json(&combined, "SARIF audit")
2920}
2921
2922fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
2925 let value = build_audit_codeclimate(result);
2926 report::emit_json(&value, "CodeClimate audit")
2927}
2928
2929fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
2930 let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
2931
2932 if let Some(ref check) = result.check {
2933 all_issues.extend(report::build_codeclimate(
2934 &check.results,
2935 &check.config.root,
2936 &check.config.rules,
2937 ));
2938 }
2939
2940 if let Some(ref dupes) = result.dupes {
2941 all_issues.extend(report::build_duplication_codeclimate(
2942 &dupes.report,
2943 &dupes.config.root,
2944 ));
2945 }
2946
2947 if let Some(ref health) = result.health {
2948 all_issues.extend(report::build_health_codeclimate(
2949 &health.report,
2950 &health.config.root,
2951 ));
2952 }
2953
2954 serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
2955}
2956
2957pub fn run_audit(opts: &AuditOptions<'_>) -> ExitCode {
2961 if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
2962 return emit_error(&e, 2, opts.output);
2963 }
2964 let coverage_resolved = opts
2972 .coverage
2973 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2974 let runtime_coverage_resolved = opts
2982 .runtime_coverage
2983 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2984 let resolved_opts = AuditOptions {
2985 coverage: coverage_resolved.as_deref(),
2986 runtime_coverage: runtime_coverage_resolved.as_deref(),
2987 ..*opts
2988 };
2989 match execute_audit(&resolved_opts) {
2990 Ok(result) => print_audit_result(&result, opts.quiet, opts.explain),
2991 Err(code) => code,
2992 }
2993}
2994
2995#[cfg(test)]
2996mod tests {
2997 use super::*;
2998 use std::{fs, process::Command};
2999
3000 fn git(dir: &std::path::Path, args: &[&str]) {
3001 let output = Command::new("git")
3002 .args(args)
3003 .current_dir(dir)
3004 .env_remove("GIT_DIR")
3005 .env_remove("GIT_WORK_TREE")
3006 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3007 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3008 .env("GIT_AUTHOR_NAME", "test")
3009 .env("GIT_AUTHOR_EMAIL", "test@test.com")
3010 .env("GIT_COMMITTER_NAME", "test")
3011 .env("GIT_COMMITTER_EMAIL", "test@test.com")
3012 .output()
3013 .expect("git command failed");
3014 assert!(
3015 output.status.success(),
3016 "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
3017 args,
3018 String::from_utf8_lossy(&output.stdout),
3019 String::from_utf8_lossy(&output.stderr)
3020 );
3021 }
3022
3023 #[test]
3024 fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
3025 let temp = std::env::temp_dir();
3026 let audit_path = temp.join("fallow-audit-base-123-456");
3027 let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
3028 let canonical_audit_path = temp
3029 .canonicalize()
3030 .unwrap_or_else(|_| temp.clone())
3031 .join("fallow-audit-base-456-789");
3032 let unrelated_temp = temp.join("other-worktree");
3033 let output = format!(
3034 "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
3035 audit_path.display(),
3036 unrelated_temp.display(),
3037 reusable_path.display()
3038 );
3039
3040 assert_eq!(
3041 parse_worktree_list(&output),
3042 vec![audit_path, reusable_path.clone()]
3043 );
3044 assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
3045 assert!(is_reusable_audit_worktree_path(&reusable_path));
3046 assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
3047 assert_eq!(
3048 audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
3049 None
3050 );
3051 assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
3052 }
3053
3054 #[test]
3055 fn base_analysis_root_preserves_repo_subdirectory_roots() {
3056 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3057 let repo = tmp.path().join("repo");
3058 let app_root = repo.join("apps/mobile");
3059 let base_worktree = tmp.path().join("base-worktree");
3060 fs::create_dir_all(&app_root).expect("app root should be created");
3061 fs::create_dir_all(&base_worktree).expect("base worktree should be created");
3062 git(&repo, &["init", "-b", "main"]);
3063
3064 assert_eq!(
3065 base_analysis_root(&app_root, &base_worktree),
3066 base_worktree.join("apps/mobile")
3067 );
3068 }
3069
3070 #[test]
3071 fn audit_base_worktree_reuses_current_node_modules_context() {
3072 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3073 let root = tmp.path();
3074 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3075 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
3076 .expect("gitignore should be written");
3077 fs::write(
3078 root.join("package.json"),
3079 r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
3080 )
3081 .expect("package.json should be written");
3082 fs::write(
3083 root.join("tsconfig.json"),
3084 r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
3085 )
3086 .expect("tsconfig should be written");
3087 fs::write(
3088 root.join("src/index.ts"),
3089 "import { used } from '@/feature';\nconsole.log(used);\n",
3090 )
3091 .expect("index should be written");
3092 fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
3093 .expect("feature should be written");
3094
3095 git(root, &["init", "-b", "main"]);
3096 git(root, &["add", "."]);
3097 git(
3098 root,
3099 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3100 );
3101
3102 let rn_config = root.join("node_modules/@react-native/typescript-config");
3103 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
3104 fs::write(
3105 rn_config.join("tsconfig.json"),
3106 r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
3107 )
3108 .expect("node_modules tsconfig should be written");
3109
3110 let worktree =
3111 BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
3112 assert!(
3113 worktree.path().join("node_modules").is_dir(),
3114 "base worktree should reuse ignored node_modules from the current checkout"
3115 );
3116 assert!(
3117 worktree
3118 .path()
3119 .join("node_modules/@react-native/typescript-config/tsconfig.json")
3120 .is_file(),
3121 "base worktree should preserve tsconfig extends targets installed in node_modules"
3122 );
3123 }
3124
3125 #[test]
3126 fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
3127 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3128 let root = tmp.path();
3129 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
3130 .expect("gitignore should be written");
3131 fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
3132 .expect("package.json should be written");
3133
3134 git(root, &["init", "-b", "main"]);
3135 git(root, &["add", "."]);
3136 git(
3137 root,
3138 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3139 );
3140
3141 let rn_config = root.join("node_modules/@react-native/typescript-config");
3142 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
3143 fs::write(rn_config.join("tsconfig.json"), "{}")
3144 .expect("node_modules tsconfig should be written");
3145
3146 let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
3147 let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
3148 .expect("persistent base worktree should be created");
3149 let worktree_path = first.path().to_path_buf();
3150 assert!(
3151 worktree_path.join("node_modules").is_dir(),
3152 "initial persistent worktree should receive node_modules context"
3153 );
3154 remove_node_modules_context(&worktree_path);
3155 assert!(
3156 !worktree_path.join("node_modules").exists(),
3157 "test setup should remove the dependency context from the reusable worktree"
3158 );
3159 drop(first);
3160
3161 let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
3162 .expect("ready persistent base worktree should be reused");
3163 assert_eq!(reused.path(), worktree_path.as_path());
3164 assert!(
3165 reused.path().join("node_modules").is_dir(),
3166 "ready persistent worktree should refresh missing node_modules context"
3167 );
3168
3169 remove_audit_worktree(root, reused.path());
3170 let _ = fs::remove_dir_all(reused.path());
3171 }
3172
3173 fn remove_node_modules_context(worktree_path: &Path) {
3174 let path = worktree_path.join("node_modules");
3175 let Ok(metadata) = fs::symlink_metadata(&path) else {
3176 return;
3177 };
3178 if metadata.file_type().is_symlink() {
3179 #[cfg(unix)]
3180 let _ = fs::remove_file(path);
3181 #[cfg(windows)]
3182 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
3183 } else {
3184 let _ = fs::remove_dir_all(path);
3185 }
3186 }
3187
3188 #[test]
3189 fn audit_base_snapshot_cache_payload_roundtrips_sets() {
3190 let key = AuditBaseSnapshotCacheKey {
3191 hash: 42,
3192 base_sha: "abc123".to_string(),
3193 };
3194 let snapshot = AuditKeySnapshot {
3195 dead_code: ["dead:a".to_string(), "dead:b".to_string()]
3196 .into_iter()
3197 .collect(),
3198 health: std::iter::once("health:a".to_string()).collect(),
3199 dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
3200 .into_iter()
3201 .collect(),
3202 };
3203
3204 let cached = cached_from_snapshot(&key, &snapshot);
3205 assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
3206 assert_eq!(cached.key_hash, key.hash);
3207 assert_eq!(cached.base_sha, key.base_sha);
3208 assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
3209
3210 let decoded = snapshot_from_cached(cached);
3211 assert_eq!(decoded.dead_code, snapshot.dead_code);
3212 assert_eq!(decoded.health, snapshot.health);
3213 assert_eq!(decoded.dupes, snapshot.dupes);
3214 }
3215
3216 #[test]
3217 fn audit_base_snapshot_cache_key_includes_extended_config() {
3218 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3219 let root = tmp.path();
3220 fs::write(
3221 root.join(".fallowrc.json"),
3222 r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
3223 )
3224 .expect("config should be written");
3225 fs::write(
3226 root.join("base.json"),
3227 r#"{"rules":{"unused-exports":"off"}}"#,
3228 )
3229 .expect("base config should be written");
3230
3231 let config_path = None;
3232 let opts = AuditOptions {
3233 root,
3234 config_path: &config_path,
3235 output: OutputFormat::Json,
3236 no_cache: false,
3237 threads: 1,
3238 quiet: true,
3239 changed_since: Some("HEAD"),
3240 production: false,
3241 production_dead_code: None,
3242 production_health: None,
3243 production_dupes: None,
3244 workspace: None,
3245 changed_workspaces: None,
3246 explain: false,
3247 explain_skipped: false,
3248 performance: false,
3249 group_by: None,
3250 dead_code_baseline: None,
3251 health_baseline: None,
3252 dupes_baseline: None,
3253 max_crap: None,
3254 coverage: None,
3255 coverage_root: None,
3256 gate: AuditGate::NewOnly,
3257 include_entry_exports: false,
3258 runtime_coverage: None,
3259 min_invocations_hot: 100,
3260 };
3261
3262 let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
3263 fs::write(
3264 root.join("base.json"),
3265 r#"{"rules":{"unused-exports":"error"}}"#,
3266 )
3267 .expect("base config should be updated");
3268 let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
3269
3270 assert_ne!(
3271 first["resolved_hash"], second["resolved_hash"],
3272 "extended config changes must invalidate cached base snapshots"
3273 );
3274 }
3275
3276 #[test]
3277 fn audit_gate_all_skips_base_snapshot() {
3278 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3279 let root = tmp.path();
3280 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3281 fs::write(
3282 root.join("package.json"),
3283 r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
3284 )
3285 .expect("package.json should be written");
3286 fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
3287 .expect("index should be written");
3288
3289 git(root, &["init", "-b", "main"]);
3290 git(root, &["add", "."]);
3291 git(
3292 root,
3293 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3294 );
3295 fs::write(
3296 root.join("src/index.ts"),
3297 "export const legacy = 1;\nexport const changed = 2;\n",
3298 )
3299 .expect("changed module should be written");
3300
3301 let config_path = None;
3302 let opts = AuditOptions {
3303 root,
3304 config_path: &config_path,
3305 output: OutputFormat::Json,
3306 no_cache: true,
3307 threads: 1,
3308 quiet: true,
3309 changed_since: Some("HEAD"),
3310 production: false,
3311 production_dead_code: None,
3312 production_health: None,
3313 production_dupes: None,
3314 workspace: None,
3315 changed_workspaces: None,
3316 explain: false,
3317 explain_skipped: false,
3318 performance: false,
3319 group_by: None,
3320 dead_code_baseline: None,
3321 health_baseline: None,
3322 dupes_baseline: None,
3323 max_crap: None,
3324 coverage: None,
3325 coverage_root: None,
3326 gate: AuditGate::All,
3327 include_entry_exports: false,
3328 runtime_coverage: None,
3329 min_invocations_hot: 100,
3330 };
3331
3332 let result = execute_audit(&opts).expect("audit should execute");
3333 assert!(result.base_snapshot.is_none());
3334 assert_eq!(result.attribution.gate, AuditGate::All);
3335 assert_eq!(result.attribution.dead_code_introduced, 0);
3336 assert_eq!(result.attribution.dead_code_inherited, 0);
3337 }
3338
3339 #[test]
3340 fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
3341 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3342 let root = tmp.path();
3343 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3344 fs::write(
3345 root.join("package.json"),
3346 r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
3347 )
3348 .expect("package.json should be written");
3349 fs::write(
3350 root.join(".fallowrc.json"),
3351 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
3352 )
3353 .expect("config should be written");
3354 let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
3355 fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
3356 fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
3357 fs::write(root.join("README.md"), "before\n").expect("readme should be written");
3358
3359 git(root, &["init", "-b", "main"]);
3360 git(root, &["add", "."]);
3361 git(
3362 root,
3363 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3364 );
3365 fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
3366 fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
3367 .expect("cache dir should be created");
3368 fs::write(
3369 root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
3370 b"cache",
3371 )
3372 .expect("cache artifact should be written");
3373
3374 let before_worktrees = audit_worktree_names(root);
3375
3376 let config_path = None;
3377 let opts = AuditOptions {
3378 root,
3379 config_path: &config_path,
3380 output: OutputFormat::Json,
3381 no_cache: true,
3382 threads: 1,
3383 quiet: true,
3384 changed_since: Some("HEAD"),
3385 production: false,
3386 production_dead_code: None,
3387 production_health: None,
3388 production_dupes: None,
3389 workspace: None,
3390 changed_workspaces: None,
3391 explain: false,
3392 explain_skipped: false,
3393 performance: true,
3394 group_by: None,
3395 dead_code_baseline: None,
3396 health_baseline: None,
3397 dupes_baseline: None,
3398 max_crap: None,
3399 coverage: None,
3400 coverage_root: None,
3401 gate: AuditGate::NewOnly,
3402 include_entry_exports: false,
3403 runtime_coverage: None,
3404 min_invocations_hot: 100,
3405 };
3406
3407 let result = execute_audit(&opts).expect("audit should execute");
3408 assert_eq!(result.verdict, AuditVerdict::Pass);
3409 assert_eq!(result.changed_files_count, 2);
3410 assert!(result.base_snapshot_skipped);
3411 assert!(result.base_snapshot.is_some());
3412
3413 let after_worktrees = audit_worktree_names(root);
3414 assert_eq!(
3415 before_worktrees, after_worktrees,
3416 "base snapshot skip must not create a temporary base worktree"
3417 );
3418 }
3419
3420 fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
3421 let mut names: Vec<String> = list_audit_worktrees(repo_root)
3422 .unwrap_or_default()
3423 .into_iter()
3424 .filter_map(|path| {
3425 path.file_name()
3426 .and_then(|name| name.to_str())
3427 .map(str::to_owned)
3428 })
3429 .collect();
3430 names.sort();
3431 names
3432 }
3433
3434 #[test]
3435 fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
3436 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3437 let root = tmp.path();
3438 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3439 fs::write(
3440 root.join("package.json"),
3441 r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
3442 )
3443 .expect("package.json should be written");
3444 fs::write(
3445 root.join("src/index.ts"),
3446 "import { used } from './used';\nused();\n",
3447 )
3448 .expect("index should be written");
3449 fs::write(
3450 root.join("src/used.ts"),
3451 "export function used() {\n return 1;\n}\n",
3452 )
3453 .expect("used module should be written");
3454
3455 git(root, &["init", "-b", "main"]);
3456 git(root, &["add", "."]);
3457 git(
3458 root,
3459 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3460 );
3461 fs::write(
3462 root.join("src/used.ts"),
3463 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
3464 )
3465 .expect("changed module should be written");
3466
3467 let config_path = None;
3468 let opts = AuditOptions {
3469 root,
3470 config_path: &config_path,
3471 output: OutputFormat::Json,
3472 no_cache: true,
3473 threads: 1,
3474 quiet: true,
3475 changed_since: Some("HEAD"),
3476 production: false,
3477 production_dead_code: None,
3478 production_health: None,
3479 production_dupes: None,
3480 workspace: None,
3481 changed_workspaces: None,
3482 explain: false,
3483 explain_skipped: false,
3484 performance: true,
3485 group_by: None,
3486 dead_code_baseline: None,
3487 health_baseline: None,
3488 dupes_baseline: None,
3489 max_crap: None,
3490 coverage: None,
3491 coverage_root: None,
3492 gate: AuditGate::NewOnly,
3493 include_entry_exports: false,
3494 runtime_coverage: None,
3495 min_invocations_hot: 100,
3496 };
3497
3498 let result = execute_audit(&opts).expect("audit should execute");
3499 let health = result.health.expect("health should run for changed files");
3500 let timings = health.timings.expect("performance timings should be kept");
3501 assert!(timings.discover_ms.abs() < f64::EPSILON);
3502 assert!(timings.parse_ms.abs() < f64::EPSILON);
3503 assert!(
3507 result.dupes.is_some(),
3508 "dupes should run when changed files exist"
3509 );
3510 }
3511
3512 #[test]
3513 fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
3514 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3518 let root = tmp.path();
3519 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3520 fs::write(
3521 root.join("package.json"),
3522 r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
3523 )
3524 .expect("package.json should be written");
3525 fs::write(
3526 root.join("src/index.ts"),
3527 "import { used } from './used';\nused();\n",
3528 )
3529 .expect("index should be written");
3530 fs::write(
3531 root.join("src/used.ts"),
3532 "export function used() {\n return 1;\n}\n",
3533 )
3534 .expect("used module should be written");
3535
3536 git(root, &["init", "-b", "main"]);
3537 git(root, &["add", "."]);
3538 git(
3539 root,
3540 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3541 );
3542 fs::write(
3543 root.join("src/used.ts"),
3544 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
3545 )
3546 .expect("changed module should be written");
3547
3548 let config_path = None;
3549 let opts = AuditOptions {
3550 root,
3551 config_path: &config_path,
3552 output: OutputFormat::Json,
3553 no_cache: true,
3554 threads: 1,
3555 quiet: true,
3556 changed_since: Some("HEAD"),
3557 production: false,
3558 production_dead_code: Some(true),
3559 production_health: Some(false),
3560 production_dupes: Some(false),
3561 workspace: None,
3562 changed_workspaces: None,
3563 explain: false,
3564 explain_skipped: false,
3565 performance: true,
3566 group_by: None,
3567 dead_code_baseline: None,
3568 health_baseline: None,
3569 dupes_baseline: None,
3570 max_crap: None,
3571 coverage: None,
3572 coverage_root: None,
3573 gate: AuditGate::NewOnly,
3574 include_entry_exports: false,
3575 runtime_coverage: None,
3576 min_invocations_hot: 100,
3577 };
3578
3579 let result = execute_audit(&opts).expect("audit should execute");
3580 assert!(result.dupes.is_some(), "dupes should still run");
3581 }
3582
3583 #[cfg(unix)]
3584 #[test]
3585 fn remap_focus_files_does_not_canonicalize_through_symlinks() {
3586 let tmp = tempfile::TempDir::new().expect("temp dir");
3596 let real = tmp.path().join("real");
3597 let link = tmp.path().join("link");
3598 fs::create_dir_all(&real).expect("real dir");
3599 std::os::unix::fs::symlink(&real, &link).expect("symlink");
3600 let canonical = link.canonicalize().expect("canonicalize symlink");
3604 assert_ne!(link, canonical, "symlink should not equal its target");
3605
3606 let from_root = PathBuf::from("/repo");
3607 let mut focus = FxHashSet::default();
3608 focus.insert(from_root.join("src/foo.ts"));
3609
3610 let remapped = remap_focus_files(&focus, &from_root, &link)
3611 .expect("remap should succeed for in-prefix files");
3612
3613 let expected = link.join("src/foo.ts");
3614 assert!(
3615 remapped.contains(&expected),
3616 "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
3617 );
3618 }
3619
3620 #[test]
3621 fn remap_focus_files_skips_paths_outside_from_root() {
3622 let from_root = PathBuf::from("/repo/apps/web");
3626 let to_root = PathBuf::from("/wt/apps/web");
3627 let mut focus = FxHashSet::default();
3628 focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
3629 focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
3630
3631 let remapped =
3632 remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
3633
3634 assert_eq!(remapped.len(), 1);
3635 assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
3636 }
3637
3638 #[test]
3639 fn remap_focus_files_returns_none_when_no_paths_map() {
3640 let from_root = PathBuf::from("/repo/apps/web");
3641 let to_root = PathBuf::from("/wt/apps/web");
3642 let mut focus = FxHashSet::default();
3643 focus.insert(PathBuf::from("/elsewhere/foo.ts"));
3644
3645 let remapped = remap_focus_files(&focus, &from_root, &to_root);
3646 assert!(
3647 remapped.is_none(),
3648 "remap should return None when no paths can be mapped, falling caller back to full corpus"
3649 );
3650 }
3651
3652 #[test]
3653 fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
3654 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3665 let root_buf = tmp
3674 .path()
3675 .canonicalize()
3676 .expect("temp root should canonicalize");
3677 let root = root_buf.as_path();
3678 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3679 fs::write(
3680 root.join("package.json"),
3681 r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
3682 )
3683 .expect("package.json should be written");
3684 fs::write(
3685 root.join(".fallowrc.json"),
3686 r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
3687 )
3688 .expect("config should be written");
3689
3690 let dup_block = "export function processItems(input: number[]): number[] {\n const doubled = input.map((value) => value * 2);\n const filtered = doubled.filter((value) => value > 0);\n const summed = filtered.reduce((acc, value) => acc + value, 0);\n const shifted = summed + 10;\n const scaled = shifted * 3;\n const rounded = Math.round(scaled / 7);\n return [rounded, scaled, summed];\n}\n";
3691 fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
3692 fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
3693
3694 git(root, &["init", "-b", "main"]);
3695 git(root, &["add", "."]);
3696 git(
3697 root,
3698 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3699 );
3700 fs::write(
3703 root.join("src/changed.ts"),
3704 format!("{dup_block}// touched\n"),
3705 )
3706 .expect("changed file should be modified");
3707 git(root, &["add", "."]);
3708 git(
3709 root,
3710 &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
3711 );
3712
3713 let config_path = None;
3714 let opts = AuditOptions {
3715 root,
3716 config_path: &config_path,
3717 output: OutputFormat::Json,
3718 no_cache: true,
3719 threads: 1,
3720 quiet: true,
3721 changed_since: Some("HEAD~1"),
3722 production: false,
3723 production_dead_code: None,
3724 production_health: None,
3725 production_dupes: None,
3726 workspace: None,
3727 changed_workspaces: None,
3728 explain: false,
3729 explain_skipped: false,
3730 performance: false,
3731 group_by: None,
3732 dead_code_baseline: None,
3733 health_baseline: None,
3734 dupes_baseline: None,
3735 max_crap: None,
3736 coverage: None,
3737 coverage_root: None,
3738 gate: AuditGate::NewOnly,
3739 include_entry_exports: false,
3740 runtime_coverage: None,
3741 min_invocations_hot: 100,
3742 };
3743
3744 let result = execute_audit(&opts).expect("audit should execute");
3745 assert!(
3746 result.base_snapshot_skipped,
3747 "comment-only JS/TS diffs should reuse current keys as the base snapshot"
3748 );
3749 let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
3750 assert!(
3751 !dupes_report.clone_groups.is_empty(),
3752 "current run should detect the pre-existing duplicate"
3753 );
3754 assert_eq!(
3755 result.attribution.duplication_introduced, 0,
3756 "pre-existing duplicate must not be classified as introduced; \
3757 attribution = {:?}",
3758 result.attribution
3759 );
3760 assert!(
3761 result.attribution.duplication_inherited > 0,
3762 "pre-existing duplicate must be classified as inherited; \
3763 attribution = {:?}",
3764 result.attribution
3765 );
3766 }
3767
3768 #[test]
3769 fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
3770 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3771 let root = tmp.path();
3772 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3773 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3774 .expect("node_modules config dir should be created");
3775 fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
3776 fs::write(
3777 root.join("package.json"),
3778 r#"{
3779 "name": "audit-react-native-tsconfig-base",
3780 "private": true,
3781 "main": "src/App.tsx",
3782 "dependencies": {
3783 "react-native": "0.80.0"
3784 }
3785 }"#,
3786 )
3787 .expect("package.json should be written");
3788 fs::write(
3789 root.join("tsconfig.json"),
3790 r#"{
3791 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3792 "compilerOptions": {
3793 "baseUrl": ".",
3794 "paths": {
3795 "@/*": ["src/*"]
3796 }
3797 },
3798 "include": ["src/**/*"]
3799 }"#,
3800 )
3801 .expect("tsconfig should be written");
3802 fs::write(
3803 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3804 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3805 )
3806 .expect("react native tsconfig should be written");
3807 fs::write(
3808 root.join("src/App.tsx"),
3809 r#"import { homeTitle } from "@/screens/Home";
3810
3811export function App() {
3812 return homeTitle;
3813}
3814"#,
3815 )
3816 .expect("app should be written");
3817 fs::write(
3818 root.join("src/screens/Home.ts"),
3819 r#"export const homeTitle = "home";
3820"#,
3821 )
3822 .expect("home should be written");
3823
3824 git(root, &["init", "-b", "main"]);
3825 git(root, &["add", "."]);
3826 git(
3827 root,
3828 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3829 );
3830 fs::write(
3831 root.join("src/App.tsx"),
3832 r#"import { homeTitle } from "@/screens/Home";
3833
3834export function App() {
3835 return homeTitle.toUpperCase();
3836}
3837"#,
3838 )
3839 .expect("app should be modified");
3840
3841 let config_path = None;
3842 let opts = AuditOptions {
3843 root,
3844 config_path: &config_path,
3845 output: OutputFormat::Json,
3846 no_cache: true,
3847 threads: 1,
3848 quiet: true,
3849 changed_since: Some("HEAD"),
3850 production: false,
3851 production_dead_code: None,
3852 production_health: None,
3853 production_dupes: None,
3854 workspace: None,
3855 changed_workspaces: None,
3856 explain: false,
3857 explain_skipped: false,
3858 performance: false,
3859 group_by: None,
3860 dead_code_baseline: None,
3861 health_baseline: None,
3862 dupes_baseline: None,
3863 max_crap: None,
3864 coverage: None,
3865 coverage_root: None,
3866 gate: AuditGate::NewOnly,
3867 include_entry_exports: false,
3868 runtime_coverage: None,
3869 min_invocations_hot: 100,
3870 };
3871
3872 let result = execute_audit(&opts).expect("audit should execute");
3873 assert!(
3874 !result.base_snapshot_skipped,
3875 "source diffs should run a real base snapshot"
3876 );
3877 let base = result
3878 .base_snapshot
3879 .as_ref()
3880 .expect("base snapshot should run");
3881 assert!(
3882 !base
3883 .dead_code
3884 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3885 "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
3886 base.dead_code
3887 );
3888 assert!(
3889 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3890 "alias target should stay reachable in the base worktree: {:?}",
3891 base.dead_code
3892 );
3893 let check = result.check.as_ref().expect("dead-code audit should run");
3894 assert!(
3895 check.results.unresolved_imports.is_empty(),
3896 "HEAD audit should also resolve @/* aliases: {:?}",
3897 check.results.unresolved_imports
3898 );
3899 }
3900
3901 #[test]
3902 fn audit_base_preserves_subdirectory_root_resolution() {
3903 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3904 let repo = tmp.path().join("repo");
3905 let root = repo.join("apps/mobile");
3906 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3907 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3908 .expect("node_modules config dir should be created");
3909 fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
3910 .expect("gitignore should be written");
3911 fs::write(
3912 root.join("package.json"),
3913 r#"{
3914 "name": "audit-subdir-react-native-tsconfig-base",
3915 "private": true,
3916 "main": "src/App.tsx",
3917 "dependencies": {
3918 "react-native": "0.80.0"
3919 }
3920 }"#,
3921 )
3922 .expect("package.json should be written");
3923 fs::write(
3924 root.join("tsconfig.json"),
3925 r#"{
3926 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3927 "compilerOptions": {
3928 "baseUrl": ".",
3929 "paths": {
3930 "@/*": ["src/*"]
3931 }
3932 },
3933 "include": ["src/**/*"]
3934 }"#,
3935 )
3936 .expect("tsconfig should be written");
3937 fs::write(
3938 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3939 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3940 )
3941 .expect("react native tsconfig should be written");
3942 fs::write(
3943 root.join("src/App.tsx"),
3944 r#"import { homeTitle } from "@/screens/Home";
3945
3946export function App() {
3947 return homeTitle;
3948}
3949"#,
3950 )
3951 .expect("app should be written");
3952 fs::write(
3953 root.join("src/screens/Home.ts"),
3954 r#"export const homeTitle = "home";
3955"#,
3956 )
3957 .expect("home should be written");
3958
3959 git(&repo, &["init", "-b", "main"]);
3960 git(&repo, &["add", "."]);
3961 git(
3962 &repo,
3963 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3964 );
3965 fs::write(
3966 root.join("src/App.tsx"),
3967 r#"import { homeTitle } from "@/screens/Home";
3968
3969export function App() {
3970 return homeTitle.toUpperCase();
3971}
3972"#,
3973 )
3974 .expect("app should be modified");
3975
3976 let config_path = None;
3977 let opts = AuditOptions {
3978 root: &root,
3979 config_path: &config_path,
3980 output: OutputFormat::Json,
3981 no_cache: true,
3982 threads: 1,
3983 quiet: true,
3984 changed_since: Some("HEAD"),
3985 production: false,
3986 production_dead_code: None,
3987 production_health: None,
3988 production_dupes: None,
3989 workspace: None,
3990 changed_workspaces: None,
3991 explain: false,
3992 explain_skipped: false,
3993 performance: false,
3994 group_by: None,
3995 dead_code_baseline: None,
3996 health_baseline: None,
3997 dupes_baseline: None,
3998 max_crap: None,
3999 coverage: None,
4000 coverage_root: None,
4001 gate: AuditGate::NewOnly,
4002 include_entry_exports: false,
4003 runtime_coverage: None,
4004 min_invocations_hot: 100,
4005 };
4006
4007 let result = execute_audit(&opts).expect("audit should execute");
4008 assert!(
4009 !result.base_snapshot_skipped,
4010 "source diffs should run a real base snapshot"
4011 );
4012 let base = result
4013 .base_snapshot
4014 .as_ref()
4015 .expect("base snapshot should run");
4016 assert!(
4017 !base
4018 .dead_code
4019 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
4020 "base audit should analyze from the app subdirectory, not the repo root: {:?}",
4021 base.dead_code
4022 );
4023 assert!(
4024 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
4025 "subdirectory base audit should keep alias targets reachable: {:?}",
4026 base.dead_code
4027 );
4028 }
4029
4030 #[test]
4031 fn audit_base_uses_new_explicit_config_without_hard_failure() {
4032 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4033 let root = tmp.path();
4034 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4035 fs::write(
4036 root.join("package.json"),
4037 r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
4038 )
4039 .expect("package.json should be written");
4040 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
4041 .expect("index should be written");
4042
4043 git(root, &["init", "-b", "main"]);
4044 git(root, &["add", "."]);
4045 git(
4046 root,
4047 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4048 );
4049
4050 let explicit_config = root.join(".fallowrc.json");
4051 fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
4052 .expect("new config should be written");
4053 fs::write(root.join("src/index.ts"), "export const used = 2;\n")
4054 .expect("index should be modified");
4055
4056 let config_path = Some(explicit_config);
4057 let opts = AuditOptions {
4058 root,
4059 config_path: &config_path,
4060 output: OutputFormat::Json,
4061 no_cache: true,
4062 threads: 1,
4063 quiet: true,
4064 changed_since: Some("HEAD"),
4065 production: false,
4066 production_dead_code: None,
4067 production_health: None,
4068 production_dupes: None,
4069 workspace: None,
4070 changed_workspaces: None,
4071 explain: false,
4072 explain_skipped: false,
4073 performance: false,
4074 group_by: None,
4075 dead_code_baseline: None,
4076 health_baseline: None,
4077 dupes_baseline: None,
4078 max_crap: None,
4079 coverage: None,
4080 coverage_root: None,
4081 gate: AuditGate::NewOnly,
4082 include_entry_exports: false,
4083 runtime_coverage: None,
4084 min_invocations_hot: 100,
4085 };
4086
4087 let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
4088 assert!(
4089 result.base_snapshot.is_some(),
4090 "base snapshot should use the current explicit config even when the base commit lacks it"
4091 );
4092 }
4093
4094 #[test]
4095 fn audit_base_uses_current_discovered_config_for_attribution() {
4096 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4097 let root = tmp.path();
4098 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4099 fs::write(
4100 root.join("package.json"),
4101 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
4102 )
4103 .expect("package.json should be written");
4104 fs::write(
4105 root.join(".fallowrc.json"),
4106 r#"{"rules":{"unused-dependencies":"off"}}"#,
4107 )
4108 .expect("base config should be written");
4109 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
4110 .expect("index should be written");
4111
4112 git(root, &["init", "-b", "main"]);
4113 git(root, &["add", "."]);
4114 git(
4115 root,
4116 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4117 );
4118
4119 fs::write(
4120 root.join(".fallowrc.json"),
4121 r#"{"rules":{"unused-dependencies":"error"}}"#,
4122 )
4123 .expect("current config should be written");
4124 fs::write(
4125 root.join("package.json"),
4126 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
4127 )
4128 .expect("package.json should be touched");
4129
4130 let config_path = None;
4131 let opts = AuditOptions {
4132 root,
4133 config_path: &config_path,
4134 output: OutputFormat::Json,
4135 no_cache: true,
4136 threads: 1,
4137 quiet: true,
4138 changed_since: Some("HEAD"),
4139 production: false,
4140 production_dead_code: None,
4141 production_health: None,
4142 production_dupes: None,
4143 workspace: None,
4144 changed_workspaces: None,
4145 explain: false,
4146 explain_skipped: false,
4147 performance: false,
4148 group_by: None,
4149 dead_code_baseline: None,
4150 health_baseline: None,
4151 dupes_baseline: None,
4152 max_crap: None,
4153 coverage: None,
4154 coverage_root: None,
4155 gate: AuditGate::NewOnly,
4156 include_entry_exports: false,
4157 runtime_coverage: None,
4158 min_invocations_hot: 100,
4159 };
4160
4161 let result = execute_audit(&opts).expect("audit should execute");
4162 assert_eq!(
4163 result.attribution.dead_code_introduced, 0,
4164 "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
4165 result.attribution
4166 );
4167 assert!(
4168 result.attribution.dead_code_inherited > 0,
4169 "pre-existing changed-file findings should be classified as inherited: {:?}",
4170 result.attribution
4171 );
4172 }
4173
4174 #[test]
4175 fn audit_base_current_config_attribution_survives_cache_hit() {
4176 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4177 let root = tmp.path();
4178 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4179 fs::write(
4180 root.join("package.json"),
4181 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
4182 )
4183 .expect("package.json should be written");
4184 fs::write(
4185 root.join(".fallowrc.json"),
4186 r#"{"rules":{"unused-dependencies":"off"}}"#,
4187 )
4188 .expect("base config should be written");
4189 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
4190 .expect("index should be written");
4191
4192 git(root, &["init", "-b", "main"]);
4193 git(root, &["add", "."]);
4194 git(
4195 root,
4196 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4197 );
4198
4199 fs::write(
4200 root.join(".fallowrc.json"),
4201 r#"{"rules":{"unused-dependencies":"error"}}"#,
4202 )
4203 .expect("current config should be written");
4204 fs::write(
4205 root.join("package.json"),
4206 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
4207 )
4208 .expect("package.json should be touched");
4209
4210 let config_path = None;
4211 let opts = AuditOptions {
4212 root,
4213 config_path: &config_path,
4214 output: OutputFormat::Json,
4215 no_cache: false,
4216 threads: 1,
4217 quiet: true,
4218 changed_since: Some("HEAD"),
4219 production: false,
4220 production_dead_code: None,
4221 production_health: None,
4222 production_dupes: None,
4223 workspace: None,
4224 changed_workspaces: None,
4225 explain: false,
4226 explain_skipped: false,
4227 performance: false,
4228 group_by: None,
4229 dead_code_baseline: None,
4230 health_baseline: None,
4231 dupes_baseline: None,
4232 max_crap: None,
4233 coverage: None,
4234 coverage_root: None,
4235 gate: AuditGate::NewOnly,
4236 include_entry_exports: false,
4237 runtime_coverage: None,
4238 min_invocations_hot: 100,
4239 };
4240
4241 let first = execute_audit(&opts).expect("first audit should execute");
4242 assert_eq!(
4243 first.attribution.dead_code_introduced, 0,
4244 "first audit should classify pre-existing findings as inherited: {:?}",
4245 first.attribution
4246 );
4247
4248 let changed_files =
4249 crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
4250 let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
4251 .expect("cache key should compute")
4252 .expect("cache key should exist");
4253 assert!(
4254 load_cached_base_snapshot(&opts, &key).is_some(),
4255 "first audit should store a reusable base snapshot"
4256 );
4257
4258 let second = execute_audit(&opts).expect("second audit should execute");
4259 assert_eq!(
4260 second.attribution.dead_code_introduced, 0,
4261 "cache hit should keep current-config attribution stable: {:?}",
4262 second.attribution
4263 );
4264 assert!(
4265 second.attribution.dead_code_inherited > 0,
4266 "cache hit should preserve inherited base findings: {:?}",
4267 second.attribution
4268 );
4269 }
4270
4271 #[test]
4272 fn audit_dupes_only_materializes_groups_touching_changed_files() {
4273 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4274 let root_path = tmp
4275 .path()
4276 .canonicalize()
4277 .expect("temp root should canonicalize");
4278 let root = root_path.as_path();
4279 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4280 fs::write(
4281 root.join("package.json"),
4282 r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
4283 )
4284 .expect("package.json should be written");
4285 fs::write(
4286 root.join(".fallowrc.json"),
4287 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
4288 )
4289 .expect("config should be written");
4290
4291 let focused_code = "export function focused(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 10;\n return shifted / 2;\n}\n";
4292 let untouched_code = "export function untouched(input: string): string {\n const lowered = input.toLowerCase();\n const padded = lowered.padStart(10, \"x\");\n return padded.slice(0, 8);\n}\n";
4293 fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
4294 fs::write(root.join("src/focused-copy.ts"), focused_code)
4295 .expect("focused copy should be written");
4296 fs::write(root.join("src/untouched-a.ts"), untouched_code)
4297 .expect("untouched a should be written");
4298 fs::write(root.join("src/untouched-b.ts"), untouched_code)
4299 .expect("untouched b should be written");
4300
4301 git(root, &["init", "-b", "main"]);
4302 git(root, &["add", "."]);
4303 git(
4304 root,
4305 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4306 );
4307 fs::write(
4308 root.join("src/changed.ts"),
4309 format!("{focused_code}export const changedMarker = true;\n"),
4310 )
4311 .expect("changed file should be modified");
4312
4313 let config_path = None;
4314 let opts = AuditOptions {
4315 root,
4316 config_path: &config_path,
4317 output: OutputFormat::Json,
4318 no_cache: true,
4319 threads: 1,
4320 quiet: true,
4321 changed_since: Some("HEAD"),
4322 production: false,
4323 production_dead_code: None,
4324 production_health: None,
4325 production_dupes: None,
4326 workspace: None,
4327 changed_workspaces: None,
4328 explain: false,
4329 explain_skipped: false,
4330 performance: false,
4331 group_by: None,
4332 dead_code_baseline: None,
4333 health_baseline: None,
4334 dupes_baseline: None,
4335 max_crap: None,
4336 coverage: None,
4337 coverage_root: None,
4338 gate: AuditGate::All,
4339 include_entry_exports: false,
4340 runtime_coverage: None,
4341 min_invocations_hot: 100,
4342 };
4343
4344 let result = execute_audit(&opts).expect("audit should execute");
4345 let dupes = result.dupes.expect("dupes should run");
4346 let changed_path = root.join("src/changed.ts");
4347
4348 assert!(
4349 !dupes.report.clone_groups.is_empty(),
4350 "changed file should still match unchanged duplicate code"
4351 );
4352 assert!(dupes.report.clone_groups.iter().all(|group| {
4353 group
4354 .instances
4355 .iter()
4356 .any(|instance| instance.file == changed_path)
4357 }));
4358 }
4359}