1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitCode};
4use std::time::{Duration, Instant};
5
6use fallow_config::{AuditGate, OutputFormat};
7use fallow_core::git_env::clear_ambient_git_env;
8use rustc_hash::FxHashSet;
9use xxhash_rust::xxh3::xxh3_64;
10
11use crate::base_worktree::{
12 BaseWorktree, git_rev_parse, git_toplevel, resolve_cache_max_age, sweep_old_reusable_caches,
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};
18
19const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 2;
20const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[serde(rename_all = "snake_case")]
26pub enum AuditVerdict {
27 Pass,
29 Warn,
31 Fail,
33}
34
35#[derive(Debug, Clone, serde::Serialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38pub struct AuditSummary {
39 pub dead_code_issues: usize,
40 pub dead_code_has_errors: bool,
41 pub complexity_findings: usize,
42 pub max_cyclomatic: Option<u16>,
43 pub duplication_clone_groups: usize,
44}
45
46#[derive(Debug, Default, Clone, serde::Serialize)]
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49pub struct AuditAttribution {
50 pub gate: AuditGate,
51 pub dead_code_introduced: usize,
52 pub dead_code_inherited: usize,
53 pub complexity_introduced: usize,
54 pub complexity_inherited: usize,
55 pub duplication_introduced: usize,
56 pub duplication_inherited: usize,
57}
58
59pub struct AuditResult {
61 pub verdict: AuditVerdict,
62 pub summary: AuditSummary,
63 pub attribution: AuditAttribution,
64 base_snapshot: Option<AuditKeySnapshot>,
65 pub base_snapshot_skipped: bool,
66 pub changed_files_count: usize,
67 pub changed_files: Vec<PathBuf>,
71 pub base_ref: String,
72 pub base_description: Option<String>,
77 pub head_sha: Option<String>,
78 pub output: OutputFormat,
79 pub performance: bool,
80 pub check: Option<CheckResult>,
81 pub dupes: Option<DupesResult>,
82 pub health: Option<HealthResult>,
83 pub elapsed: Duration,
84}
85
86pub struct AuditOptions<'a> {
87 pub root: &'a std::path::Path,
88 pub config_path: &'a Option<std::path::PathBuf>,
89 pub cache_dir: &'a std::path::Path,
90 pub output: OutputFormat,
91 pub no_cache: bool,
92 pub threads: usize,
93 pub quiet: bool,
94 pub changed_since: Option<&'a str>,
95 pub production: bool,
96 pub production_dead_code: Option<bool>,
97 pub production_health: Option<bool>,
98 pub production_dupes: Option<bool>,
99 pub workspace: Option<&'a [String]>,
100 pub changed_workspaces: Option<&'a str>,
101 pub explain: bool,
102 pub explain_skipped: bool,
103 pub performance: bool,
104 pub group_by: Option<crate::GroupBy>,
105 pub dead_code_baseline: Option<&'a std::path::Path>,
107 pub health_baseline: Option<&'a std::path::Path>,
109 pub dupes_baseline: Option<&'a std::path::Path>,
111 pub max_crap: Option<f64>,
114 pub coverage: Option<&'a std::path::Path>,
116 pub coverage_root: Option<&'a std::path::Path>,
118 pub gate: AuditGate,
119 pub include_entry_exports: bool,
121 pub runtime_coverage: Option<&'a std::path::Path>,
127 pub min_invocations_hot: u64,
129}
130
131struct DetectedBase {
134 git_ref: String,
137 description: String,
140}
141
142fn git_stdout(root: &std::path::Path, args: &[&str]) -> Option<String> {
145 let mut command = std::process::Command::new("git");
146 command.args(args).current_dir(root);
147 clear_ambient_git_env(&mut command);
148 let output = command.output().ok()?;
149 if !output.status.success() {
150 return None;
151 }
152 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
153 if trimmed.is_empty() {
154 None
155 } else {
156 Some(trimmed)
157 }
158}
159
160fn git_ref_exists(root: &std::path::Path, git_ref: &str) -> bool {
162 git_stdout(root, &["rev-parse", "--verify", "--quiet", git_ref]).is_some()
163}
164
165fn git_upstream_ref(root: &std::path::Path) -> Option<String> {
168 git_stdout(
169 root,
170 &[
171 "rev-parse",
172 "--abbrev-ref",
173 "--symbolic-full-name",
174 "@{upstream}",
175 ],
176 )
177}
178
179fn git_merge_base(root: &std::path::Path, a: &str, b: &str) -> Option<String> {
182 git_stdout(root, &["merge-base", a, b])
183}
184
185fn detect_remote_default_ref(root: &std::path::Path) -> Option<String> {
189 if let Some(full_ref) = git_stdout(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
190 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
191 {
192 return Some(format!("origin/{branch}"));
193 }
194 for candidate in ["origin/main", "origin/master"] {
195 if git_ref_exists(root, candidate) {
196 return Some(candidate.to_string());
197 }
198 }
199 None
200}
201
202fn auto_detect_base_ref(root: &std::path::Path) -> Option<DetectedBase> {
224 if let Some(upstream) = git_upstream_ref(root) {
225 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
226 return Some(DetectedBase {
227 git_ref: sha,
228 description: format!("merge-base with {upstream}"),
229 });
230 }
231 return Some(DetectedBase {
234 description: format!("{upstream} (tip)"),
235 git_ref: upstream,
236 });
237 }
238
239 if let Some(remote_ref) = detect_remote_default_ref(root) {
240 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
241 return Some(DetectedBase {
242 git_ref: sha,
243 description: format!("merge-base with {remote_ref}"),
244 });
245 }
246 return Some(DetectedBase {
247 description: format!("{remote_ref} (tip)"),
248 git_ref: remote_ref,
249 });
250 }
251
252 for candidate in ["main", "master"] {
253 if git_ref_exists(root, candidate) {
254 return Some(DetectedBase {
255 git_ref: candidate.to_string(),
256 description: format!("local {candidate}"),
257 });
258 }
259 }
260
261 None
262}
263
264fn get_head_sha(root: &std::path::Path) -> Option<String> {
266 let mut command = std::process::Command::new("git");
267 command
268 .args(["rev-parse", "--short", "HEAD"])
269 .current_dir(root);
270 clear_ambient_git_env(&mut command);
271 let output = command.output().ok()?;
272 if output.status.success() {
273 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
274 } else {
275 None
276 }
277}
278
279fn compute_verdict(
280 check: Option<&CheckResult>,
281 dupes: Option<&DupesResult>,
282 health: Option<&HealthResult>,
283) -> AuditVerdict {
284 let mut has_errors = false;
285 let mut has_warnings = false;
286
287 if let Some(result) = check {
288 if crate::check::has_error_severity_issues(
289 &result.results,
290 &result.config.rules,
291 Some(&result.config),
292 ) {
293 has_errors = true;
294 } else if result.results.total_issues() > 0 {
295 has_warnings = true;
296 }
297 }
298
299 if let Some(result) = health
300 && !result.report.findings.is_empty()
301 {
302 has_errors = true;
303 }
304
305 if let Some(result) = dupes
306 && !result.report.clone_groups.is_empty()
307 {
308 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
309 has_errors = true;
310 } else {
311 has_warnings = true;
312 }
313 }
314
315 if has_errors {
316 AuditVerdict::Fail
317 } else if has_warnings {
318 AuditVerdict::Warn
319 } else {
320 AuditVerdict::Pass
321 }
322}
323
324fn build_summary(
325 check: Option<&CheckResult>,
326 dupes: Option<&DupesResult>,
327 health: Option<&HealthResult>,
328) -> AuditSummary {
329 let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
330 let dead_code_has_errors = check.is_some_and(|r| {
331 crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
332 });
333 let complexity_findings = health.map_or(0, |r| r.report.findings.len());
334 let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
335 let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
336
337 AuditSummary {
338 dead_code_issues,
339 dead_code_has_errors,
340 complexity_findings,
341 max_cyclomatic,
342 duplication_clone_groups,
343 }
344}
345
346fn compute_audit_attribution(
347 check: Option<&CheckResult>,
348 dupes: Option<&DupesResult>,
349 health: Option<&HealthResult>,
350 base: Option<&AuditKeySnapshot>,
351 gate: AuditGate,
352) -> AuditAttribution {
353 let dead_code = check
354 .map(|r| {
355 count_introduced(
356 &dead_code_keys(&r.results, &r.config.root),
357 base.map(|b| &b.dead_code),
358 )
359 })
360 .unwrap_or_default();
361 let complexity = health
362 .map(|r| {
363 count_introduced(
364 &health_keys(&r.report, &r.config.root),
365 base.map(|b| &b.health),
366 )
367 })
368 .unwrap_or_default();
369 let duplication = dupes
370 .map(|r| {
371 count_introduced(
372 &dupes_keys(&r.report, &r.config.root),
373 base.map(|b| &b.dupes),
374 )
375 })
376 .unwrap_or_default();
377
378 AuditAttribution {
379 gate,
380 dead_code_introduced: dead_code.0,
381 dead_code_inherited: dead_code.1,
382 complexity_introduced: complexity.0,
383 complexity_inherited: complexity.1,
384 duplication_introduced: duplication.0,
385 duplication_inherited: duplication.1,
386 }
387}
388
389fn compute_introduced_verdict(
390 check: Option<&CheckResult>,
391 dupes: Option<&DupesResult>,
392 health: Option<&HealthResult>,
393 base: Option<&AuditKeySnapshot>,
394) -> AuditVerdict {
395 let mut has_errors = false;
396 let mut has_warnings = false;
397
398 if let Some(result) = check {
399 let base_keys = base.map(|b| &b.dead_code);
400 let mut introduced = result.results.clone();
401 retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
402 if crate::check::has_error_severity_issues(
403 &introduced,
404 &result.config.rules,
405 Some(&result.config),
406 ) {
407 has_errors = true;
408 } else if introduced.total_issues() > 0 {
409 has_warnings = true;
410 }
411 }
412
413 if let Some(result) = health {
414 let base_keys = base.map(|b| &b.health);
415 let introduced = result
416 .report
417 .findings
418 .iter()
419 .filter(|finding| {
420 !base_keys.is_some_and(|keys| {
421 keys.contains(&health_finding_key(finding, &result.config.root))
422 })
423 })
424 .count();
425 if introduced > 0 {
426 has_errors = true;
427 }
428 }
429
430 if let Some(result) = dupes {
431 let base_keys = base.map(|b| &b.dupes);
432 let introduced = result
433 .report
434 .clone_groups
435 .iter()
436 .filter(|group| {
437 !base_keys
438 .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
439 })
440 .count();
441 if introduced > 0 {
442 if result.threshold > 0.0
443 && result.report.stats.duplication_percentage > result.threshold
444 {
445 has_errors = true;
446 } else {
447 has_warnings = true;
448 }
449 }
450 }
451
452 if has_errors {
453 AuditVerdict::Fail
454 } else if has_warnings {
455 AuditVerdict::Warn
456 } else {
457 AuditVerdict::Pass
458 }
459}
460
461struct AuditKeySnapshot {
462 dead_code: FxHashSet<String>,
463 health: FxHashSet<String>,
464 dupes: FxHashSet<String>,
465}
466
467struct AuditBaseSnapshotCacheKey {
468 hash: u64,
469 base_sha: String,
470}
471
472#[derive(bitcode::Encode, bitcode::Decode)]
473struct CachedAuditKeySnapshot {
474 version: u8,
475 cli_version: String,
476 key_hash: u64,
477 base_sha: String,
478 dead_code: Vec<String>,
479 health: Vec<String>,
480 dupes: Vec<String>,
481}
482
483fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
484 let Some(base) = base else {
485 return (0, 0);
486 };
487 keys.iter().fold((0, 0), |(introduced, inherited), key| {
488 if base.contains(key) {
489 (introduced, inherited + 1)
490 } else {
491 (introduced + 1, inherited)
492 }
493 })
494}
495
496fn sorted_keys(keys: &FxHashSet<String>) -> Vec<String> {
497 let mut keys: Vec<String> = keys.iter().cloned().collect();
498 keys.sort_unstable();
499 keys
500}
501
502fn snapshot_from_cached(cached: CachedAuditKeySnapshot) -> AuditKeySnapshot {
503 AuditKeySnapshot {
504 dead_code: cached.dead_code.into_iter().collect(),
505 health: cached.health.into_iter().collect(),
506 dupes: cached.dupes.into_iter().collect(),
507 }
508}
509
510fn cached_from_snapshot(
511 key: &AuditBaseSnapshotCacheKey,
512 snapshot: &AuditKeySnapshot,
513) -> CachedAuditKeySnapshot {
514 CachedAuditKeySnapshot {
515 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
516 cli_version: env!("CARGO_PKG_VERSION").to_string(),
517 key_hash: key.hash,
518 base_sha: key.base_sha.clone(),
519 dead_code: sorted_keys(&snapshot.dead_code),
520 health: sorted_keys(&snapshot.health),
521 dupes: sorted_keys(&snapshot.dupes),
522 }
523}
524
525fn audit_base_snapshot_cache_dir(cache_dir: &Path) -> PathBuf {
526 cache_dir
527 .join("cache")
528 .join(format!("audit-base-v{AUDIT_BASE_SNAPSHOT_CACHE_VERSION}"))
529}
530
531fn audit_base_snapshot_cache_file(cache_dir: &Path, key: &AuditBaseSnapshotCacheKey) -> PathBuf {
532 audit_base_snapshot_cache_dir(cache_dir).join(format!("{:016x}.bin", key.hash))
533}
534
535fn ensure_audit_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
536 std::fs::create_dir_all(dir)?;
537 let gitignore = dir.join(".gitignore");
538 if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
539 std::fs::write(gitignore, "*\n")?;
540 }
541 Ok(())
542}
543
544fn load_cached_base_snapshot(
545 opts: &AuditOptions<'_>,
546 key: &AuditBaseSnapshotCacheKey,
547) -> Option<AuditKeySnapshot> {
548 let path = audit_base_snapshot_cache_file(opts.cache_dir, key);
549 let data = std::fs::read(path).ok()?;
550 if data.len() > MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE {
551 return None;
552 }
553 let cached: CachedAuditKeySnapshot = bitcode::decode(&data).ok()?;
554 if cached.version != AUDIT_BASE_SNAPSHOT_CACHE_VERSION
555 || cached.cli_version != env!("CARGO_PKG_VERSION")
556 || cached.key_hash != key.hash
557 || cached.base_sha != key.base_sha
558 {
559 return None;
560 }
561 Some(snapshot_from_cached(cached))
562}
563
564fn save_cached_base_snapshot(
565 opts: &AuditOptions<'_>,
566 key: &AuditBaseSnapshotCacheKey,
567 snapshot: &AuditKeySnapshot,
568) {
569 let dir = audit_base_snapshot_cache_dir(opts.cache_dir);
570 if ensure_audit_base_snapshot_cache_dir(&dir).is_err() {
571 return;
572 }
573 let data = bitcode::encode(&cached_from_snapshot(key, snapshot));
574 let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
575 return;
576 };
577 if tmp.write_all(&data).is_err() {
578 return;
579 }
580 let _ = tmp.persist(audit_base_snapshot_cache_file(opts.cache_dir, key));
581}
582
583fn ambient_git_env_hint() -> Option<String> {
588 use fallow_core::git_env::AMBIENT_GIT_ENV_VARS;
589 for var in AMBIENT_GIT_ENV_VARS {
590 if let Ok(value) = std::env::var(var)
591 && !value.is_empty()
592 {
593 return Some(format!(
594 "{var}={value} is set in the environment; if fallow is being \
595invoked from a git hook this can interfere with worktree operations. Re-run \
596with `env -u {var} fallow audit` to confirm."
597 ));
598 }
599 }
600 None
601}
602
603fn normalized_changed_files(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
604 let git_root = git_toplevel(root);
605 let mut files: Vec<String> = changed_files
606 .iter()
607 .map(|path| {
608 git_root
609 .as_ref()
610 .and_then(|root| path.strip_prefix(root).ok())
611 .unwrap_or(path)
612 .to_string_lossy()
613 .replace('\\', "/")
614 })
615 .collect();
616 files.sort_unstable();
617 files
618}
619
620fn config_file_fingerprint(opts: &AuditOptions<'_>) -> Result<serde_json::Value, ExitCode> {
621 let loaded = if let Some(path) = opts.config_path {
622 let config = fallow_config::FallowConfig::load(path).map_err(|e| {
623 emit_error(
624 &format!("failed to load config '{}': {e}", path.display()),
625 2,
626 opts.output,
627 )
628 })?;
629 Some((config, path.clone()))
630 } else {
631 fallow_config::FallowConfig::find_and_load(opts.root)
632 .map_err(|e| emit_error(&e, 2, opts.output))?
633 };
634
635 let Some((config, path)) = loaded else {
636 return Ok(serde_json::json!({
637 "path": null,
638 "resolved_hash": null,
639 }));
640 };
641 let bytes = serde_json::to_vec(&config).map_err(|e| {
642 emit_error(
643 &format!("failed to serialize resolved config for audit cache key: {e}"),
644 2,
645 opts.output,
646 )
647 })?;
648 Ok(serde_json::json!({
649 "path": path.to_string_lossy(),
650 "resolved_hash": format!("{:016x}", xxh3_64(&bytes)),
651 }))
652}
653
654fn coverage_file_fingerprint(path: &Path, project_root: &Path) -> serde_json::Value {
655 let resolved = crate::health::scoring::resolve_relative_to_root(path, Some(project_root));
656 let file_path = if resolved.is_dir() {
657 resolved.join("coverage-final.json")
658 } else {
659 resolved
660 };
661 match std::fs::read(&file_path) {
662 Ok(bytes) => serde_json::json!({
663 "path": path.to_string_lossy(),
664 "resolved_path": file_path.to_string_lossy(),
665 "content_hash": format!("{:016x}", xxh3_64(&bytes)),
666 "len": bytes.len(),
667 }),
668 Err(err) => serde_json::json!({
669 "path": path.to_string_lossy(),
670 "resolved_path": file_path.to_string_lossy(),
671 "error": err.kind().to_string(),
672 }),
673 }
674}
675
676fn audit_base_snapshot_cache_key(
677 opts: &AuditOptions<'_>,
678 base_ref: &str,
679 changed_files: &FxHashSet<PathBuf>,
680) -> Result<Option<AuditBaseSnapshotCacheKey>, ExitCode> {
681 if opts.no_cache {
682 return Ok(None);
683 }
684 let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
685 return Ok(None);
686 };
687 let config_file = config_file_fingerprint(opts)?;
688 let coverage_file = opts
689 .coverage
690 .map(|p| coverage_file_fingerprint(p, opts.root));
691 let payload = serde_json::json!({
692 "cache_version": AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
693 "cli_version": env!("CARGO_PKG_VERSION"),
694 "base_sha": base_sha,
695 "config_file": config_file,
696 "changed_files": normalized_changed_files(opts.root, changed_files),
697 "production": opts.production,
698 "production_dead_code": opts.production_dead_code,
699 "production_health": opts.production_health,
700 "production_dupes": opts.production_dupes,
701 "workspace": opts.workspace,
702 "changed_workspaces": opts.changed_workspaces,
703 "group_by": opts.group_by.map(|g| format!("{g:?}")),
704 "include_entry_exports": opts.include_entry_exports,
705 "max_crap": opts.max_crap,
706 "coverage": coverage_file,
707 "coverage_root": opts.coverage_root.map(|p| p.to_string_lossy().to_string()),
708 "dead_code_baseline": opts.dead_code_baseline.map(|p| p.to_string_lossy().to_string()),
709 "health_baseline": opts.health_baseline.map(|p| p.to_string_lossy().to_string()),
710 "dupes_baseline": opts.dupes_baseline.map(|p| p.to_string_lossy().to_string()),
711 });
712 let bytes = serde_json::to_vec(&payload).map_err(|e| {
713 emit_error(
714 &format!("failed to build audit cache key: {e}"),
715 2,
716 opts.output,
717 )
718 })?;
719 Ok(Some(AuditBaseSnapshotCacheKey {
720 hash: xxh3_64(&bytes),
721 base_sha,
722 }))
723}
724
725fn compute_base_snapshot(
726 opts: &AuditOptions<'_>,
727 base_ref: &str,
728 changed_files: &FxHashSet<PathBuf>,
729 base_sha: Option<&str>,
730) -> Result<AuditKeySnapshot, ExitCode> {
731 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
732 use std::fmt::Write as _;
733 let mut message =
734 format!("could not create a temporary worktree for base ref '{base_ref}'");
735 if let Some(hint) = ambient_git_env_hint() {
736 let _ = write!(message, "\n hint: {hint}");
737 }
738 return Err(emit_error(&message, 2, opts.output));
739 };
740 let base_root = base_analysis_root(opts.root, worktree.path());
741 let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
742 let current_config_path = opts
743 .config_path
744 .clone()
745 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
746 let base_opts = AuditOptions {
747 root: &base_root,
748 config_path: ¤t_config_path,
749 cache_dir: &base_cache_dir,
750 output: opts.output,
751 no_cache: opts.no_cache,
752 threads: opts.threads,
753 quiet: true,
754 changed_since: None,
755 production: opts.production,
756 production_dead_code: opts.production_dead_code,
757 production_health: opts.production_health,
758 production_dupes: opts.production_dupes,
759 workspace: opts.workspace,
760 changed_workspaces: None,
761 explain: false,
762 explain_skipped: false,
763 performance: false,
764 group_by: opts.group_by,
765 dead_code_baseline: None,
766 health_baseline: None,
767 dupes_baseline: None,
768 max_crap: opts.max_crap,
769 coverage: opts.coverage,
770 coverage_root: opts.coverage_root,
771 gate: AuditGate::All,
772 include_entry_exports: opts.include_entry_exports,
773 runtime_coverage: None,
774 min_invocations_hot: opts.min_invocations_hot,
775 };
776
777 let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
778 let check_production = opts.production_dead_code.unwrap_or(opts.production);
779 let health_production = opts.production_health.unwrap_or(opts.production);
780 let share_dead_code_parse_with_health = check_production == health_production;
781
782 let (check_res, dupes_res) = rayon::join(
783 || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
784 || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
785 );
786 let mut check = check_res?;
787 let dupes = dupes_res?;
788 let shared_parse = if share_dead_code_parse_with_health {
789 check.as_mut().and_then(|r| r.shared_parse.take())
790 } else {
791 None
792 };
793 let health = run_audit_health(&base_opts, None, shared_parse)?;
794 if let Some(ref mut check) = check {
795 check.shared_parse = None;
796 }
797
798 Ok(AuditKeySnapshot {
799 dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
800 dead_code_keys(&r.results, &r.config.root)
801 }),
802 health: health.as_ref().map_or_else(FxHashSet::default, |r| {
803 health_keys(&r.report, &r.config.root)
804 }),
805 dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
806 dupes_keys(&r.report, &r.config.root)
807 }),
808 })
809}
810
811fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
812 let Some(git_root) = git_toplevel(current_root) else {
813 return base_worktree_root.to_path_buf();
814 };
815 let current_root =
816 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
817 match current_root.strip_prefix(&git_root) {
818 Ok(relative) => base_worktree_root.join(relative),
819 Err(err) => {
820 tracing::warn!(
821 current_root = %current_root.display(),
822 git_root = %git_root.display(),
823 error = %err,
824 "Could not remap audit base root into the base worktree; falling back to worktree root"
825 );
826 base_worktree_root.to_path_buf()
827 }
828 }
829}
830
831fn current_keys_as_base_keys(
832 check: Option<&CheckResult>,
833 dupes: Option<&DupesResult>,
834 health: Option<&HealthResult>,
835) -> AuditKeySnapshot {
836 AuditKeySnapshot {
837 dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
838 dead_code_keys(&r.results, &r.config.root)
839 }),
840 health: health.as_ref().map_or_else(FxHashSet::default, |r| {
841 health_keys(&r.report, &r.config.root)
842 }),
843 dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
844 dupes_keys(&r.report, &r.config.root)
845 }),
846 }
847}
848
849fn can_reuse_current_as_base(
850 opts: &AuditOptions<'_>,
851 base_ref: &str,
852 changed_files: &FxHashSet<PathBuf>,
853) -> bool {
854 let Some(git_root) = git_toplevel(opts.root) else {
855 return false;
856 };
857 let cache_dir = opts.cache_dir.to_path_buf();
858 let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
859 changed_files.iter().all(|path| {
860 if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
861 return true;
862 }
863 if !is_analysis_input(path) {
864 return is_non_behavioral_doc(path);
865 }
866 let Ok(current) = std::fs::read_to_string(path) else {
867 return false;
868 };
869 let Some(relative) = path.strip_prefix(&git_root).ok() else {
870 return false;
871 };
872 let Some(base) = git_show_file(opts.root, base_ref, relative) else {
873 return false;
874 };
875 if current == base {
876 return true;
877 }
878 js_ts_tokens_equivalent(path, ¤t, &base)
879 })
880}
881
882fn is_fallow_cache_artifact(
883 path: &Path,
884 cache_dir: &Path,
885 canonical_cache_dir: Option<&Path>,
886) -> bool {
887 path.starts_with(cache_dir)
888 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
889}
890
891fn remap_cache_dir_for_base_worktree(
892 current_root: &Path,
893 base_worktree_root: &Path,
894 cache_dir: &Path,
895) -> PathBuf {
896 if cache_dir.is_absolute()
897 && let Ok(relative) = cache_dir.strip_prefix(current_root)
898 {
899 return base_worktree_root.join(relative);
900 }
901 cache_dir.to_path_buf()
902}
903
904fn git_show_file(root: &Path, base_ref: &str, relative: &Path) -> Option<String> {
905 let spec = format!(
906 "{}:{}",
907 base_ref,
908 relative.to_string_lossy().replace('\\', "/")
909 );
910 let mut command = Command::new("git");
911 command
912 .args(["show", "--end-of-options", &spec])
913 .current_dir(root);
914 clear_ambient_git_env(&mut command);
915 let output = command.output().ok()?;
916 output
917 .status
918 .success()
919 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
920}
921
922fn is_analysis_input(path: &Path) -> bool {
923 matches!(
924 path.extension().and_then(|ext| ext.to_str()),
925 Some(
926 "js" | "jsx"
927 | "ts"
928 | "tsx"
929 | "mjs"
930 | "mts"
931 | "cjs"
932 | "cts"
933 | "vue"
934 | "svelte"
935 | "astro"
936 | "mdx"
937 | "css"
938 | "scss"
939 )
940 )
941}
942
943fn is_non_behavioral_doc(path: &Path) -> bool {
944 matches!(
945 path.extension().and_then(|ext| ext.to_str()),
946 Some("md" | "markdown" | "txt" | "rst" | "adoc")
947 )
948}
949
950fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
951 if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
952 return false;
953 }
954 if !matches!(
955 path.extension().and_then(|ext| ext.to_str()),
956 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
957 ) {
958 return false;
959 }
960 let current_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, current, false);
961 let base_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, base, false);
962 current_tokens
963 .tokens
964 .iter()
965 .map(|token| &token.kind)
966 .eq(base_tokens.tokens.iter().map(|token| &token.kind))
967}
968
969fn remap_focus_files(
970 files: &FxHashSet<PathBuf>,
971 from_root: &Path,
972 to_root: &Path,
973) -> Option<FxHashSet<PathBuf>> {
974 let mut remapped = FxHashSet::default();
975 for file in files {
976 if let Ok(relative) = file.strip_prefix(from_root) {
977 remapped.insert(to_root.join(relative));
978 }
979 }
980 if remapped.is_empty() {
981 return None;
982 }
983 Some(remapped)
984}
985
986#[cfg(test)]
987use std::time::SystemTime;
988
989#[cfg(test)]
990use crate::base_worktree::{
991 ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
992 is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
993 materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
994 remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
995 sweep_orphan_audit_worktrees, touch_last_used,
996};
997
998#[path = "audit_keys.rs"]
999mod keys;
1000
1001use keys::{
1002 dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
1003 retain_introduced_dead_code,
1004};
1005
1006struct HeadAnalyses {
1007 check: Option<CheckResult>,
1008 dupes: Option<DupesResult>,
1009 health: Option<HealthResult>,
1010}
1011
1012fn run_audit_head_analyses(
1019 opts: &AuditOptions<'_>,
1020 changed_since: Option<&str>,
1021 changed_files: &FxHashSet<PathBuf>,
1022) -> Result<HeadAnalyses, ExitCode> {
1023 let check_production = opts.production_dead_code.unwrap_or(opts.production);
1024 let health_production = opts.production_health.unwrap_or(opts.production);
1025 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
1026 let share_dead_code_parse_with_health = check_production == health_production;
1027 let share_dead_code_files_with_dupes =
1028 share_dead_code_parse_with_health && check_production == dupes_production;
1029
1030 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
1031 let dupes_files = if share_dead_code_files_with_dupes {
1032 check
1033 .as_ref()
1034 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
1035 } else {
1036 None
1037 };
1038 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
1039 let shared_parse = if share_dead_code_parse_with_health {
1040 check.as_mut().and_then(|r| r.shared_parse.take())
1041 } else {
1042 None
1043 };
1044 let health = run_audit_health(opts, changed_since, shared_parse)?;
1045 Ok(HeadAnalyses {
1046 check,
1047 dupes,
1048 health,
1049 })
1050}
1051
1052pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1054 let start = Instant::now();
1055
1056 let (base_ref, base_description) = resolve_base_ref(opts)?;
1057
1058 sweep_old_reusable_caches(
1062 opts.root,
1063 resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1064 opts.quiet,
1065 );
1066
1067 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1068 return Err(emit_error(
1069 &format!(
1070 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1071 ),
1072 2,
1073 opts.output,
1074 ));
1075 };
1076 let changed_files_count = changed_files.len();
1077
1078 if changed_files.is_empty() {
1079 return Ok(empty_audit_result(
1080 base_ref,
1081 base_description,
1082 opts,
1083 start.elapsed(),
1084 ));
1085 }
1086
1087 let changed_since = Some(base_ref.as_str());
1088
1089 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1090 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1091 let base_cache_key = if needs_real_base_snapshot {
1092 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1093 } else {
1094 None
1095 };
1096 let cached_base_snapshot = base_cache_key
1097 .as_ref()
1098 .and_then(|key| load_cached_base_snapshot(opts, key));
1099
1100 let (head_res, base_res) = if needs_real_base_snapshot && cached_base_snapshot.is_none() {
1101 let base_sha = base_cache_key.as_ref().map(|key| key.base_sha.as_str());
1102 let (h, b) = rayon::join(
1103 || run_audit_head_analyses(opts, changed_since, &changed_files),
1104 || compute_base_snapshot(opts, &base_ref, &changed_files, base_sha),
1105 );
1106 (h, Some(b))
1107 } else {
1108 (
1109 run_audit_head_analyses(opts, changed_since, &changed_files),
1110 None,
1111 )
1112 };
1113
1114 let head = head_res?;
1115 let mut check_result = head.check;
1116 let dupes_result = head.dupes;
1117 let health_result = head.health;
1118
1119 let (base_snapshot, base_snapshot_skipped) = if matches!(opts.gate, AuditGate::NewOnly) {
1120 if let Some(snapshot) = cached_base_snapshot {
1121 (Some(snapshot), false)
1122 } else if let Some(base_res) = base_res {
1123 let snapshot = base_res?;
1124 if let Some(ref key) = base_cache_key {
1125 save_cached_base_snapshot(opts, key, &snapshot);
1126 }
1127 (Some(snapshot), false)
1128 } else {
1129 (
1130 Some(current_keys_as_base_keys(
1131 check_result.as_ref(),
1132 dupes_result.as_ref(),
1133 health_result.as_ref(),
1134 )),
1135 true,
1136 )
1137 }
1138 } else {
1139 (None, false)
1140 };
1141 if let Some(ref mut check) = check_result {
1142 check.shared_parse = None;
1143 }
1144 let attribution = compute_audit_attribution(
1145 check_result.as_ref(),
1146 dupes_result.as_ref(),
1147 health_result.as_ref(),
1148 base_snapshot.as_ref(),
1149 opts.gate,
1150 );
1151 let verdict = if matches!(opts.gate, AuditGate::NewOnly) {
1152 compute_introduced_verdict(
1153 check_result.as_ref(),
1154 dupes_result.as_ref(),
1155 health_result.as_ref(),
1156 base_snapshot.as_ref(),
1157 )
1158 } else {
1159 compute_verdict(
1160 check_result.as_ref(),
1161 dupes_result.as_ref(),
1162 health_result.as_ref(),
1163 )
1164 };
1165 let summary = build_summary(
1166 check_result.as_ref(),
1167 dupes_result.as_ref(),
1168 health_result.as_ref(),
1169 );
1170 crate::telemetry::note_final_result_count(
1171 summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1172 );
1173
1174 Ok(AuditResult {
1175 verdict,
1176 summary,
1177 attribution,
1178 base_snapshot,
1179 base_snapshot_skipped,
1180 changed_files_count,
1181 changed_files: changed_files.into_iter().collect(),
1182 base_ref,
1183 base_description,
1184 head_sha: get_head_sha(opts.root),
1185 output: opts.output,
1186 performance: opts.performance,
1187 check: check_result,
1188 dupes: dupes_result,
1189 health: health_result,
1190 elapsed: start.elapsed(),
1191 })
1192}
1193
1194fn parse_audit_base_override(raw: Option<String>) -> Option<String> {
1197 let trimmed = raw?.trim().to_string();
1198 if trimmed.is_empty() {
1199 None
1200 } else {
1201 Some(trimmed)
1202 }
1203}
1204
1205fn audit_base_env_override() -> Option<String> {
1209 parse_audit_base_override(std::env::var("FALLOW_AUDIT_BASE").ok())
1210}
1211
1212fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<(String, Option<String>), ExitCode> {
1216 if let Some(ref_str) = opts.changed_since {
1217 return Ok((ref_str.to_string(), None));
1218 }
1219 if let Some(env_ref) = audit_base_env_override() {
1220 if let Err(e) = crate::validate::validate_git_ref(&env_ref) {
1221 return Err(emit_error(
1222 &format!("FALLOW_AUDIT_BASE='{env_ref}' is not a valid git ref: {e}"),
1223 2,
1224 opts.output,
1225 ));
1226 }
1227 let description = format!("FALLOW_AUDIT_BASE={env_ref}");
1228 return Ok((env_ref, Some(description)));
1229 }
1230 let Some(detected) = auto_detect_base_ref(opts.root) else {
1231 return Err(emit_error(
1232 "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
1233 2,
1234 opts.output,
1235 ));
1236 };
1237 if let Err(e) = crate::validate::validate_git_ref(&detected.git_ref) {
1238 return Err(emit_error(
1239 &format!(
1240 "auto-detected base ref '{}' is not a valid git ref: {e}",
1241 detected.git_ref
1242 ),
1243 2,
1244 opts.output,
1245 ));
1246 }
1247 Ok((detected.git_ref, Some(detected.description)))
1248}
1249
1250fn empty_audit_result(
1252 base_ref: String,
1253 base_description: Option<String>,
1254 opts: &AuditOptions<'_>,
1255 elapsed: Duration,
1256) -> AuditResult {
1257 crate::telemetry::note_final_result_count(0);
1258
1259 AuditResult {
1260 verdict: AuditVerdict::Pass,
1261 summary: AuditSummary {
1262 dead_code_issues: 0,
1263 dead_code_has_errors: false,
1264 complexity_findings: 0,
1265 max_cyclomatic: None,
1266 duplication_clone_groups: 0,
1267 },
1268 attribution: AuditAttribution {
1269 gate: opts.gate,
1270 ..AuditAttribution::default()
1271 },
1272 base_snapshot: None,
1273 base_snapshot_skipped: false,
1274 changed_files_count: 0,
1275 changed_files: Vec::new(),
1276 base_ref,
1277 base_description,
1278 head_sha: get_head_sha(opts.root),
1279 output: opts.output,
1280 performance: opts.performance,
1281 check: None,
1282 dupes: None,
1283 health: None,
1284 elapsed,
1285 }
1286}
1287
1288fn run_audit_check<'a>(
1290 opts: &'a AuditOptions<'a>,
1291 changed_since: Option<&'a str>,
1292 retain_modules_for_health: bool,
1293) -> Result<Option<CheckResult>, ExitCode> {
1294 let filters = IssueFilters::default();
1295 let trace_opts = TraceOptions {
1296 trace_export: None,
1297 trace_file: None,
1298 trace_dependency: None,
1299 performance: opts.performance,
1300 };
1301 match crate::check::execute_check(&CheckOptions {
1302 root: opts.root,
1303 config_path: opts.config_path,
1304 output: opts.output,
1305 no_cache: opts.no_cache,
1306 threads: opts.threads,
1307 quiet: opts.quiet,
1308 fail_on_issues: false,
1309 filters: &filters,
1310 changed_since,
1311 diff_index: None,
1312 use_shared_diff_index: true,
1313 baseline: opts.dead_code_baseline,
1314 save_baseline: None,
1315 sarif_file: None,
1316 production: opts.production_dead_code.unwrap_or(opts.production),
1317 production_override: opts.production_dead_code,
1318 workspace: opts.workspace,
1319 changed_workspaces: opts.changed_workspaces,
1320 group_by: opts.group_by,
1321 include_dupes: false,
1322 trace_opts: &trace_opts,
1323 explain: opts.explain,
1324 top: None,
1325 file: &[],
1326 include_entry_exports: opts.include_entry_exports,
1327 summary: false,
1328 regression_opts: crate::regression::RegressionOpts {
1329 fail_on_regression: false,
1330 tolerance: crate::regression::Tolerance::Absolute(0),
1331 regression_baseline_file: None,
1332 save_target: crate::regression::SaveRegressionTarget::None,
1333 scoped: true,
1334 quiet: opts.quiet,
1335 output: opts.output,
1336 },
1337 retain_modules_for_health,
1338 defer_performance: false,
1339 }) {
1340 Ok(r) => Ok(Some(r)),
1341 Err(code) => Err(code),
1342 }
1343}
1344
1345fn run_audit_dupes<'a>(
1351 opts: &'a AuditOptions<'a>,
1352 changed_since: Option<&'a str>,
1353 changed_files: Option<&'a FxHashSet<PathBuf>>,
1354 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1355) -> Result<Option<DupesResult>, ExitCode> {
1356 let dupes_cfg = match crate::load_config_for_analysis(
1357 opts.root,
1358 opts.config_path,
1359 opts.output,
1360 opts.no_cache,
1361 opts.threads,
1362 opts.production_dupes
1363 .or_else(|| opts.production.then_some(true)),
1364 opts.quiet,
1365 fallow_config::ProductionAnalysis::Dupes,
1366 ) {
1367 Ok(c) => c.duplicates,
1368 Err(code) => return Err(code),
1369 };
1370 let dupes_opts = DupesOptions {
1371 root: opts.root,
1372 config_path: opts.config_path,
1373 output: opts.output,
1374 no_cache: opts.no_cache,
1375 threads: opts.threads,
1376 quiet: opts.quiet,
1377 mode: Some(DupesMode::from(dupes_cfg.mode)),
1378 min_tokens: Some(dupes_cfg.min_tokens),
1379 min_lines: Some(dupes_cfg.min_lines),
1380 min_occurrences: Some(dupes_cfg.min_occurrences),
1381 threshold: Some(dupes_cfg.threshold),
1382 skip_local: dupes_cfg.skip_local,
1383 cross_language: dupes_cfg.cross_language,
1384 ignore_imports: dupes_cfg.ignore_imports,
1385 top: None,
1386 baseline_path: opts.dupes_baseline,
1387 save_baseline_path: None,
1388 production: opts.production_dupes.unwrap_or(opts.production),
1389 production_override: opts.production_dupes,
1390 trace: None,
1391 changed_since,
1392 diff_index: None,
1393 use_shared_diff_index: true,
1394 changed_files,
1395 workspace: opts.workspace,
1396 changed_workspaces: opts.changed_workspaces,
1397 explain: opts.explain,
1398 explain_skipped: opts.explain_skipped,
1399 summary: false,
1400 group_by: opts.group_by,
1401 performance: false,
1402 };
1403 let dupes_run = if let Some(files) = pre_discovered {
1404 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1405 } else {
1406 crate::dupes::execute_dupes(&dupes_opts)
1407 };
1408 match dupes_run {
1409 Ok(r) => Ok(Some(r)),
1410 Err(code) => Err(code),
1411 }
1412}
1413
1414fn run_audit_health<'a>(
1416 opts: &'a AuditOptions<'a>,
1417 changed_since: Option<&'a str>,
1418 shared_parse: Option<crate::health::SharedParseData>,
1419) -> Result<Option<HealthResult>, ExitCode> {
1420 let runtime_coverage = match opts.runtime_coverage {
1421 Some(path) => match crate::health::coverage::prepare_options(
1422 path,
1423 opts.min_invocations_hot,
1424 None,
1425 None,
1426 opts.output,
1427 ) {
1428 Ok(options) => Some(options),
1429 Err(code) => return Err(code),
1430 },
1431 None => None,
1432 };
1433
1434 let health_opts = HealthOptions {
1435 root: opts.root,
1436 config_path: opts.config_path,
1437 output: opts.output,
1438 no_cache: opts.no_cache,
1439 threads: opts.threads,
1440 quiet: opts.quiet,
1441 max_cyclomatic: None,
1442 max_cognitive: None,
1443 max_crap: opts.max_crap,
1444 top: None,
1445 sort: SortBy::Cyclomatic,
1446 production: opts.production_health.unwrap_or(opts.production),
1447 production_override: opts.production_health,
1448 changed_since,
1449 diff_index: None,
1450 use_shared_diff_index: true,
1451 workspace: opts.workspace,
1452 changed_workspaces: opts.changed_workspaces,
1453 baseline: opts.health_baseline,
1454 save_baseline: None,
1455 complexity: true,
1456 complexity_breakdown: false,
1457 file_scores: false,
1458 coverage_gaps: false,
1459 config_activates_coverage_gaps: false,
1460 hotspots: false,
1461 ownership: false,
1462 ownership_emails: None,
1463 targets: false,
1464 force_full: false,
1465 score_only_output: false,
1466 enforce_coverage_gap_gate: false,
1467 effort: None,
1468 score: false,
1469 min_score: None,
1470 since: None,
1471 min_commits: None,
1472 explain: opts.explain,
1473 summary: false,
1474 save_snapshot: None,
1475 trend: false,
1476 group_by: opts.group_by,
1477 coverage: opts.coverage,
1478 coverage_root: opts.coverage_root,
1479 performance: opts.performance,
1480 min_severity: None,
1481 report_only: false,
1482 runtime_coverage,
1483 churn_file: None,
1485 };
1486 let health_run = if let Some(shared) = shared_parse {
1487 crate::health::execute_health_with_shared_parse(&health_opts, shared)
1488 } else {
1489 crate::health::execute_health(&health_opts)
1490 };
1491 match health_run {
1492 Ok(r) => Ok(Some(r)),
1493 Err(code) => Err(code),
1494 }
1495}
1496
1497#[path = "audit_output.rs"]
1498mod output;
1499
1500pub use output::print_audit_result;
1501
1502pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
1508 if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
1509 return emit_error(&e, 2, opts.output);
1510 }
1511 let coverage_resolved = opts
1512 .coverage
1513 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1514 let runtime_coverage_resolved = opts
1515 .runtime_coverage
1516 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1517 let resolved_opts = AuditOptions {
1518 coverage: coverage_resolved.as_deref(),
1519 runtime_coverage: runtime_coverage_resolved.as_deref(),
1520 ..*opts
1521 };
1522 match execute_audit(&resolved_opts) {
1523 Ok(result) => {
1524 let mut findings = result
1525 .check
1526 .as_ref()
1527 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
1528 .unwrap_or_default();
1529 if let Some(health) = result.health.as_ref() {
1530 findings.extend(crate::impact::collect_complexity_findings(&health.report));
1531 }
1532 let clones = result
1533 .dupes
1534 .as_ref()
1535 .map(|d| crate::impact::collect_clone_findings(&d.report))
1536 .unwrap_or_default();
1537 let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
1538 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
1539 c.results.active_suppressions.as_slice()
1540 });
1541 let attribution = crate::impact::AttributionInput {
1542 root: opts.root,
1543 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
1544 findings,
1545 clones,
1546 suppressions,
1547 };
1548 crate::impact::record_audit_run(
1549 opts.root,
1550 &result.summary,
1551 &crate::impact::AuditRunRecord {
1552 verdict: result.verdict,
1553 gate: gate_marker.is_some(),
1554 git_sha: result.head_sha.as_deref(),
1555 version: env!("CARGO_PKG_VERSION"),
1556 timestamp: &crate::vital_signs::chrono_timestamp(),
1557 attribution: Some(&attribution),
1558 },
1559 );
1560 print_audit_result(&result, opts.quiet, opts.explain)
1561 }
1562 Err(code) => code,
1563 }
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568 use super::*;
1569 use std::{fs, process::Command};
1570
1571 fn git(dir: &std::path::Path, args: &[&str]) {
1572 let output = Command::new("git")
1573 .args(args)
1574 .current_dir(dir)
1575 .env_remove("GIT_DIR")
1576 .env_remove("GIT_WORK_TREE")
1577 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1578 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1579 .env("GIT_AUTHOR_NAME", "test")
1580 .env("GIT_AUTHOR_EMAIL", "test@test.com")
1581 .env("GIT_COMMITTER_NAME", "test")
1582 .env("GIT_COMMITTER_EMAIL", "test@test.com")
1583 .output()
1584 .expect("git command failed");
1585 assert!(
1586 output.status.success(),
1587 "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
1588 args,
1589 String::from_utf8_lossy(&output.stdout),
1590 String::from_utf8_lossy(&output.stderr)
1591 );
1592 }
1593
1594 #[test]
1595 fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
1596 let temp = std::env::temp_dir();
1597 let audit_path = temp.join("fallow-audit-base-123-456");
1598 let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
1599 let canonical_audit_path = temp
1600 .canonicalize()
1601 .unwrap_or_else(|_| temp.clone())
1602 .join("fallow-audit-base-456-789");
1603 let unrelated_temp = temp.join("other-worktree");
1604 let output = format!(
1605 "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
1606 audit_path.display(),
1607 unrelated_temp.display(),
1608 reusable_path.display()
1609 );
1610
1611 assert_eq!(
1612 parse_worktree_list(&output),
1613 vec![audit_path, reusable_path.clone()]
1614 );
1615 assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
1616 assert!(is_reusable_audit_worktree_path(&reusable_path));
1617 assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
1618 assert_eq!(
1619 audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
1620 None
1621 );
1622 assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
1623 }
1624
1625 fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
1629 let root = parent.join(name);
1630 fs::create_dir_all(&root).expect("repo root should be created");
1631 fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
1632 git(&root, &["init", "-b", "main"]);
1633 git(&root, &["add", "."]);
1634 git(
1635 &root,
1636 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1637 );
1638 root
1639 }
1640
1641 fn commit_file(repo: &std::path::Path, name: &str, body: &str) -> String {
1643 fs::write(repo.join(name), body).expect("file should be written");
1644 git(repo, &["add", "."]);
1645 git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", name]);
1646 git_rev_parse(repo, "HEAD").expect("HEAD should resolve")
1647 }
1648
1649 #[test]
1650 fn auto_detect_base_ref_resolves_origin_default_to_merge_base() {
1651 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1652 let repo = init_throwaway_repo(tmp.path(), "repo");
1653 let head = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
1654 git(&repo, &["branch", "trunk"]);
1655 git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
1656 git(
1657 &repo,
1658 &[
1659 "symbolic-ref",
1660 "refs/remotes/origin/HEAD",
1661 "refs/remotes/origin/trunk",
1662 ],
1663 );
1664
1665 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1666 assert_eq!(detected.git_ref, head);
1669 assert_eq!(detected.description, "merge-base with origin/trunk");
1670 }
1671
1672 #[test]
1677 fn auto_detect_base_ref_ignores_stale_local_main() {
1678 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1679 let repo = init_throwaway_repo(tmp.path(), "repo");
1680 let stale = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
1681
1682 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1684 git(
1685 &repo,
1686 &[
1687 "symbolic-ref",
1688 "refs/remotes/origin/HEAD",
1689 "refs/remotes/origin/main",
1690 ],
1691 );
1692 let fork_point = commit_file(&repo, "teammate.txt", "merged work\n");
1693 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1694
1695 git(&repo, &["checkout", "-b", "feature", &fork_point]);
1698 commit_file(&repo, "feature.txt", "my change\n");
1699 git(&repo, &["branch", "-f", "main", &stale]);
1700
1701 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1702 assert_eq!(
1703 detected.git_ref, fork_point,
1704 "base must be the fork point (origin/main), not stale local main"
1705 );
1706 assert_ne!(
1707 detected.git_ref, stale,
1708 "must not diff against stale local main"
1709 );
1710 assert_eq!(detected.description, "merge-base with origin/main");
1711 }
1712
1713 #[test]
1714 fn auto_detect_base_ref_prefers_configured_upstream() {
1715 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1716 let repo = init_throwaway_repo(tmp.path(), "repo");
1717 let fork_point = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
1718 git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
1721 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1722
1723 git(&repo, &["checkout", "-b", "feature"]);
1724 git(
1725 &repo,
1726 &["branch", "--set-upstream-to=origin/main", "feature"],
1727 );
1728 commit_file(&repo, "feature.txt", "my change\n");
1729
1730 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1731 assert_eq!(detected.git_ref, fork_point);
1732 assert_eq!(detected.description, "merge-base with origin/main");
1733 }
1734
1735 #[test]
1736 fn auto_detect_base_ref_falls_back_to_local_main_without_remote() {
1737 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1738 let repo = init_throwaway_repo(tmp.path(), "repo");
1739
1740 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1741 assert_eq!(detected.git_ref, "main");
1742 assert_eq!(detected.description, "local main");
1743 }
1744
1745 #[test]
1746 fn auto_detect_base_ref_falls_back_to_local_master_without_remote() {
1747 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1748 let repo = tmp.path().join("repo");
1749 fs::create_dir_all(&repo).expect("repo root should be created");
1750 fs::write(repo.join("README.md"), "seed\n").expect("seed file should be written");
1751 git(&repo, &["init", "-b", "master"]);
1752 git(&repo, &["add", "."]);
1753 git(
1754 &repo,
1755 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1756 );
1757
1758 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1759 assert_eq!(detected.git_ref, "master");
1760 assert_eq!(detected.description, "local master");
1761 }
1762
1763 #[test]
1764 fn auto_detect_base_ref_returns_none_outside_git_repo() {
1765 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1766
1767 assert!(auto_detect_base_ref(tmp.path()).is_none());
1768 }
1769
1770 #[test]
1771 fn parse_audit_base_override_trims_and_rejects_empty() {
1772 assert_eq!(parse_audit_base_override(None), None);
1773 assert_eq!(parse_audit_base_override(Some(String::new())), None);
1774 assert_eq!(parse_audit_base_override(Some(" ".to_string())), None);
1775 assert_eq!(
1776 parse_audit_base_override(Some(" origin/main ".to_string())),
1777 Some("origin/main".to_string())
1778 );
1779 }
1780
1781 #[test]
1785 fn auto_detect_base_ref_falls_back_to_remote_tip_without_common_ancestor() {
1786 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1787 let repo = init_throwaway_repo(tmp.path(), "repo");
1788 git(&repo, &["checkout", "--orphan", "unrelated"]);
1791 commit_file(&repo, "unrelated.txt", "no shared history\n");
1792 let unrelated = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
1793 git(
1794 &repo,
1795 &["update-ref", "refs/remotes/origin/main", &unrelated],
1796 );
1797 git(
1798 &repo,
1799 &[
1800 "symbolic-ref",
1801 "refs/remotes/origin/HEAD",
1802 "refs/remotes/origin/main",
1803 ],
1804 );
1805 git(&repo, &["checkout", "main"]);
1806
1807 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1808 assert_eq!(detected.git_ref, "origin/main");
1809 assert_eq!(detected.description, "origin/main (tip)");
1810 }
1811
1812 #[test]
1813 fn get_head_sha_returns_short_head_for_git_repo() {
1814 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1815 let repo = init_throwaway_repo(tmp.path(), "repo");
1816 let output = Command::new("git")
1817 .args(["rev-parse", "--short", "HEAD"])
1818 .current_dir(&repo)
1819 .env_remove("GIT_DIR")
1820 .env_remove("GIT_WORK_TREE")
1821 .output()
1822 .expect("git rev-parse should run");
1823 assert!(output.status.success());
1824
1825 assert_eq!(
1826 get_head_sha(&repo),
1827 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1828 );
1829 }
1830
1831 #[test]
1832 fn get_head_sha_returns_none_outside_git_repo() {
1833 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1834
1835 assert_eq!(get_head_sha(tmp.path()), None);
1836 }
1837
1838 fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1839 list_audit_worktrees(repo_root)
1840 .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
1841 }
1842
1843 fn worktree_admin_entry_present(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1851 let basename = worktree_path
1852 .file_name()
1853 .and_then(|n| n.to_str())
1854 .expect("reusable worktree path has a utf-8 basename");
1855 let output = Command::new("git")
1856 .args(["worktree", "list", "--porcelain"])
1857 .current_dir(repo_root)
1858 .env_remove("GIT_DIR")
1859 .env_remove("GIT_WORK_TREE")
1860 .output()
1861 .expect("git worktree list should run");
1862 String::from_utf8_lossy(&output.stdout)
1863 .lines()
1864 .filter_map(|line| line.strip_prefix("worktree "))
1865 .any(|p| p.ends_with(basename))
1866 }
1867
1868 #[test]
1869 fn worktree_cleanup_guard_runs_on_drop() {
1870 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1871 let repo = init_throwaway_repo(tmp.path(), "repo");
1872 let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
1873
1874 git(
1875 &repo,
1876 &[
1877 "worktree",
1878 "add",
1879 "--detach",
1880 "--quiet",
1881 worktree_path.to_str().expect("path is utf-8"),
1882 "HEAD",
1883 ],
1884 );
1885 assert!(worktree_path.is_dir());
1886 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1887
1888 {
1889 let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1890 }
1891
1892 assert!(
1893 !worktree_path.exists(),
1894 "guard Drop should remove the worktree directory",
1895 );
1896 assert!(
1897 !worktree_is_registered_with_git(&repo, &worktree_path),
1898 "guard Drop should remove the git worktree registration",
1899 );
1900 }
1901
1902 #[test]
1903 fn worktree_cleanup_guard_defused_skips_drop() {
1904 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1905 let repo = init_throwaway_repo(tmp.path(), "repo");
1906 let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
1907
1908 git(
1909 &repo,
1910 &[
1911 "worktree",
1912 "add",
1913 "--detach",
1914 "--quiet",
1915 worktree_path.to_str().expect("path is utf-8"),
1916 "HEAD",
1917 ],
1918 );
1919 assert!(worktree_path.is_dir());
1920
1921 {
1922 let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1923 guard.defuse();
1924 guard.defuse();
1925 }
1926
1927 assert!(
1928 worktree_path.is_dir(),
1929 "defused guard must not remove the worktree on drop",
1930 );
1931 assert!(
1932 worktree_is_registered_with_git(&repo, &worktree_path),
1933 "defused guard must not unregister the worktree from git",
1934 );
1935
1936 remove_audit_worktree(&repo, &worktree_path);
1937 let _ = fs::remove_dir_all(&worktree_path);
1938 }
1939
1940 #[test]
1941 fn audit_orphan_sweep_removes_dead_pid_worktree() {
1942 const DEAD_PID: u32 = 99_999_999;
1943 assert!(!process_is_alive(DEAD_PID));
1944
1945 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1946 let repo = init_throwaway_repo(tmp.path(), "repo");
1947
1948 let worktree_path = std::env::temp_dir().join(format!(
1949 "fallow-audit-base-{}-{}",
1950 DEAD_PID,
1951 std::time::SystemTime::now()
1952 .duration_since(std::time::UNIX_EPOCH)
1953 .expect("clock should be after epoch")
1954 .as_nanos()
1955 ));
1956 git(
1957 &repo,
1958 &[
1959 "worktree",
1960 "add",
1961 "--detach",
1962 "--quiet",
1963 worktree_path.to_str().expect("path is utf-8"),
1964 "HEAD",
1965 ],
1966 );
1967 assert!(worktree_path.is_dir());
1968 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1969
1970 sweep_orphan_audit_worktrees(&repo);
1971
1972 assert!(
1973 !worktree_path.exists(),
1974 "sweep should remove worktree owned by a dead PID",
1975 );
1976 assert!(
1977 !worktree_is_registered_with_git(&repo, &worktree_path),
1978 "sweep should unregister worktree owned by a dead PID",
1979 );
1980 }
1981
1982 #[test]
1983 fn audit_orphan_sweep_keeps_live_pid_worktree() {
1984 let live_pid = std::process::id();
1985 assert!(process_is_alive(live_pid));
1986
1987 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1988 let repo = init_throwaway_repo(tmp.path(), "repo");
1989
1990 let worktree_path = std::env::temp_dir().join(format!(
1991 "fallow-audit-base-{}-{}",
1992 live_pid,
1993 std::time::SystemTime::now()
1994 .duration_since(std::time::UNIX_EPOCH)
1995 .expect("clock should be after epoch")
1996 .as_nanos()
1997 ));
1998 git(
1999 &repo,
2000 &[
2001 "worktree",
2002 "add",
2003 "--detach",
2004 "--quiet",
2005 worktree_path.to_str().expect("path is utf-8"),
2006 "HEAD",
2007 ],
2008 );
2009
2010 sweep_orphan_audit_worktrees(&repo);
2011
2012 assert!(
2013 worktree_path.is_dir(),
2014 "sweep must not remove worktree owned by a live PID",
2015 );
2016 assert!(
2017 worktree_is_registered_with_git(&repo, &worktree_path),
2018 "sweep must not unregister worktree owned by a live PID",
2019 );
2020
2021 remove_audit_worktree(&repo, &worktree_path);
2022 let _ = fs::remove_dir_all(&worktree_path);
2023 }
2024
2025 fn make_reusable_path(label: &str) -> PathBuf {
2029 let nanos = std::time::SystemTime::now()
2030 .duration_since(std::time::UNIX_EPOCH)
2031 .expect("clock should be after epoch")
2032 .as_nanos();
2033 std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
2034 }
2035
2036 fn register_reusable_worktree(repo: &Path, path: &Path) {
2040 git(
2041 repo,
2042 &[
2043 "worktree",
2044 "add",
2045 "--detach",
2046 "--quiet",
2047 path.to_str().expect("path is utf-8"),
2048 "HEAD",
2049 ],
2050 );
2051 }
2052
2053 fn write_sidecar_with_age(path: &Path, age: Duration) {
2054 let sidecar = reusable_worktree_last_used_path(path);
2055 let file = std::fs::OpenOptions::new()
2056 .create(true)
2057 .truncate(false)
2058 .write(true)
2059 .open(&sidecar)
2060 .expect("sidecar should open");
2061 let when = SystemTime::now()
2062 .checked_sub(age)
2063 .expect("backdated time should fit in SystemTime");
2064 file.set_modified(when)
2065 .expect("set_modified should succeed");
2066 }
2067
2068 fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
2071 remove_audit_worktree(repo, path);
2072 let _ = fs::remove_dir_all(path);
2073 let _ = fs::remove_file(reusable_worktree_last_used_path(path));
2074 let _ = fs::remove_file(reusable_worktree_lock_path(path));
2075 }
2076
2077 #[test]
2078 fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
2079 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2080 let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
2081 let worktree_path = make_reusable_path("gc-remove");
2082 register_reusable_worktree(&repo, &worktree_path);
2083 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2084
2085 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2086
2087 assert!(
2088 !worktree_path.exists(),
2089 "sweep should remove worktree dir whose sidecar is older than the threshold",
2090 );
2091 assert!(
2092 !worktree_is_registered_with_git(&repo, &worktree_path),
2093 "sweep should unregister the worktree from git",
2094 );
2095 assert!(
2096 !reusable_worktree_last_used_path(&worktree_path).exists(),
2097 "sweep should remove the sidecar `.last-used` file alongside the worktree",
2098 );
2099 cleanup_reusable_worktree(&repo, &worktree_path);
2100 }
2101
2102 #[test]
2103 fn reusable_cache_gc_keeps_fresh_entry() {
2104 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2105 let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
2106 let worktree_path = make_reusable_path("gc-keep");
2107 register_reusable_worktree(&repo, &worktree_path);
2108 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
2109
2110 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2111
2112 assert!(
2113 worktree_path.is_dir(),
2114 "sweep must not remove a worktree whose sidecar is fresher than the threshold",
2115 );
2116 assert!(
2117 worktree_is_registered_with_git(&repo, &worktree_path),
2118 "sweep must not unregister a fresh worktree",
2119 );
2120 cleanup_reusable_worktree(&repo, &worktree_path);
2121 }
2122
2123 #[test]
2124 fn reusable_cache_gc_skips_locked_entry() {
2125 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2126 let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
2127 let worktree_path = make_reusable_path("gc-locked");
2128 register_reusable_worktree(&repo, &worktree_path);
2129 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2130
2131 let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
2132 .expect("test should acquire the lock first");
2133
2134 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2135
2136 assert!(
2137 worktree_path.is_dir(),
2138 "sweep must skip a locked entry even when its sidecar is stale",
2139 );
2140 assert!(
2141 worktree_is_registered_with_git(&repo, &worktree_path),
2142 "sweep must not unregister a locked entry",
2143 );
2144 drop(lock);
2145 cleanup_reusable_worktree(&repo, &worktree_path);
2146 }
2147
2148 #[test]
2149 fn reusable_cache_gc_grace_when_sidecar_absent() {
2150 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2151 let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
2152 let worktree_path = make_reusable_path("gc-grace");
2153 register_reusable_worktree(&repo, &worktree_path);
2154 let sidecar = reusable_worktree_last_used_path(&worktree_path);
2155 assert!(
2156 !sidecar.exists(),
2157 "test pre-condition: sidecar should not exist",
2158 );
2159
2160 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2161
2162 assert!(
2163 worktree_path.is_dir(),
2164 "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
2165 );
2166 assert!(
2167 sidecar.exists(),
2168 "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
2169 );
2170 let mtime = std::fs::metadata(&sidecar)
2171 .and_then(|m| m.modified())
2172 .expect("seeded sidecar should have a readable mtime");
2173 let age = SystemTime::now()
2174 .duration_since(mtime)
2175 .unwrap_or(Duration::ZERO);
2176 assert!(
2177 age < Duration::from_mins(1),
2178 "seeded sidecar mtime should be near `now()`, got age {age:?}",
2179 );
2180 cleanup_reusable_worktree(&repo, &worktree_path);
2181 }
2182
2183 #[test]
2184 fn reusable_cache_gc_reclaims_prunable_orphan_when_dir_missing() {
2185 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2186 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
2187 let worktree_path = make_reusable_path("gc-orphan");
2188 register_reusable_worktree(&repo, &worktree_path);
2189 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
2192 let sidecar = reusable_worktree_last_used_path(&worktree_path);
2193
2194 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
2197 assert!(
2198 !worktree_path.exists(),
2199 "test pre-condition: cache dir should be gone",
2200 );
2201 assert!(
2202 worktree_admin_entry_present(&repo, &worktree_path),
2203 "test pre-condition: git admin entry should still be registered (prunable)",
2204 );
2205 assert!(
2206 sidecar.exists(),
2207 "test pre-condition: sidecar survives a dir-only reaper",
2208 );
2209
2210 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2211
2212 assert!(
2213 !worktree_admin_entry_present(&repo, &worktree_path),
2214 "sweep should unregister a prunable orphan whose dir was externally removed",
2215 );
2216 assert!(
2217 !sidecar.exists(),
2218 "sweep should remove the stale sidecar for a reclaimed orphan",
2219 );
2220 cleanup_reusable_worktree(&repo, &worktree_path);
2221 }
2222
2223 #[test]
2224 fn reusable_cache_gc_reclaims_prunable_orphan_even_when_age_gc_disabled() {
2225 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2226 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
2227 let worktree_path = make_reusable_path("gc-orphan-nogc");
2228 register_reusable_worktree(&repo, &worktree_path);
2229 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
2230 let sidecar = reusable_worktree_last_used_path(&worktree_path);
2231 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
2232 assert!(
2233 worktree_admin_entry_present(&repo, &worktree_path),
2234 "test pre-condition: git admin entry should still be registered (prunable)",
2235 );
2236 assert!(
2237 sidecar.exists(),
2238 "test pre-condition: sidecar survives a dir-only reaper",
2239 );
2240
2241 sweep_old_reusable_caches(&repo, None, true);
2244
2245 assert!(
2246 !worktree_admin_entry_present(&repo, &worktree_path),
2247 "orphan reclaim must run even when age-based GC is disabled",
2248 );
2249 assert!(
2250 !sidecar.exists(),
2251 "sweep should remove the stale sidecar even when age-based GC is disabled",
2252 );
2253 cleanup_reusable_worktree(&repo, &worktree_path);
2254 }
2255
2256 #[test]
2257 fn reusable_cache_gc_preserves_lock_file_after_removal() {
2258 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2259 let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
2260 let worktree_path = make_reusable_path("gc-lockfile");
2261 register_reusable_worktree(&repo, &worktree_path);
2262 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2263 let lock_path = reusable_worktree_lock_path(&worktree_path);
2264 drop(
2265 ReusableWorktreeLock::try_acquire(&worktree_path)
2266 .expect("test should acquire the lock"),
2267 );
2268 assert!(
2269 lock_path.exists(),
2270 "test pre-condition: lock file should exist before sweep",
2271 );
2272
2273 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2274
2275 assert!(
2276 !worktree_path.exists(),
2277 "sweep should still remove the worktree directory",
2278 );
2279 assert!(
2280 lock_path.exists(),
2281 "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
2282 );
2283 let _ = fs::remove_file(&lock_path);
2284 cleanup_reusable_worktree(&repo, &worktree_path);
2285 }
2286
2287 #[test]
2288 fn reuse_or_create_stamps_sidecar_on_fresh_create() {
2289 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2290 let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
2291 let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2292
2293 let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
2294 .expect("fresh reuse_or_create should succeed on a clean repo");
2295 let cache_path = worktree.path().to_path_buf();
2296 let sidecar = reusable_worktree_last_used_path(&cache_path);
2297
2298 assert!(
2299 sidecar.exists(),
2300 "fresh-create must write the sidecar so age is measured from now",
2301 );
2302 let initial_age = std::fs::metadata(&sidecar)
2303 .and_then(|m| m.modified())
2304 .ok()
2305 .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
2306 .expect("sidecar mtime should be readable and not in the future");
2307 assert!(
2308 initial_age < Duration::from_mins(1),
2309 "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
2310 );
2311
2312 drop(worktree);
2313 cleanup_reusable_worktree(&repo, &cache_path);
2314 }
2315
2316 #[test]
2317 fn days_to_duration_zero_disables() {
2318 assert!(days_to_duration(0).is_none());
2319 assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
2320 assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
2321 }
2322
2323 #[test]
2324 fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
2325 let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
2326 let sidecar = reusable_worktree_last_used_path(&cache_dir);
2327 assert_eq!(sidecar.parent(), cache_dir.parent());
2328 assert_eq!(
2329 sidecar.file_name().and_then(|s| s.to_str()),
2330 Some("fallow-audit-base-cache-abcd-1234.last-used"),
2331 );
2332 }
2333
2334 #[test]
2335 fn touch_last_used_creates_sidecar_if_missing() {
2336 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2337 let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
2338 fs::create_dir(&cache_dir).expect("cache dir should be created");
2339 let sidecar = reusable_worktree_last_used_path(&cache_dir);
2340 assert!(!sidecar.exists(), "sidecar should not exist before touch");
2341
2342 touch_last_used(&cache_dir);
2343
2344 assert!(sidecar.exists(), "touch should create the sidecar");
2345 let mtime = fs::metadata(&sidecar)
2346 .and_then(|m| m.modified())
2347 .expect("sidecar should have an mtime");
2348 let age = SystemTime::now()
2349 .duration_since(mtime)
2350 .unwrap_or(Duration::ZERO);
2351 assert!(
2352 age < Duration::from_mins(1),
2353 "touched sidecar should be near `now()`",
2354 );
2355 }
2356
2357 #[test]
2358 fn reusable_worktree_lock_excludes_concurrent_acquires() {
2359 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2360 let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
2361 let lock_path = reusable_worktree_lock_path(&reusable);
2362
2363 let first = ReusableWorktreeLock::try_acquire(&reusable)
2364 .expect("first acquire on a fresh path should succeed");
2365 assert!(
2366 ReusableWorktreeLock::try_acquire(&reusable).is_none(),
2367 "second acquire must fail while the first is held",
2368 );
2369 drop(first);
2370 assert!(
2371 lock_path.exists(),
2372 "lock file must persist after drop (only the kernel lock is released)",
2373 );
2374 }
2375
2376 #[test]
2377 fn base_analysis_root_preserves_repo_subdirectory_roots() {
2378 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2379 let repo = tmp.path().join("repo");
2380 let app_root = repo.join("apps/mobile");
2381 let base_worktree = tmp.path().join("base-worktree");
2382 fs::create_dir_all(&app_root).expect("app root should be created");
2383 fs::create_dir_all(&base_worktree).expect("base worktree should be created");
2384 git(&repo, &["init", "-b", "main"]);
2385
2386 assert_eq!(
2387 base_analysis_root(&app_root, &base_worktree),
2388 base_worktree.join("apps/mobile")
2389 );
2390 }
2391
2392 #[test]
2393 fn audit_base_worktree_reuses_current_node_modules_context() {
2394 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2395 let root = tmp.path();
2396 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2397 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2398 .expect("gitignore should be written");
2399 fs::write(
2400 root.join("package.json"),
2401 r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
2402 )
2403 .expect("package.json should be written");
2404 fs::write(
2405 root.join("tsconfig.json"),
2406 r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
2407 )
2408 .expect("tsconfig should be written");
2409 fs::write(
2410 root.join("src/index.ts"),
2411 "import { used } from '@/feature';\nconsole.log(used);\n",
2412 )
2413 .expect("index should be written");
2414 fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
2415 .expect("feature should be written");
2416
2417 git(root, &["init", "-b", "main"]);
2418 git(root, &["add", "."]);
2419 git(
2420 root,
2421 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2422 );
2423
2424 let rn_config = root.join("node_modules/@react-native/typescript-config");
2425 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2426 fs::write(
2427 rn_config.join("tsconfig.json"),
2428 r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
2429 )
2430 .expect("node_modules tsconfig should be written");
2431
2432 let worktree =
2433 BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
2434 assert!(
2435 worktree.path().join("node_modules").is_dir(),
2436 "base worktree should reuse ignored node_modules from the current checkout"
2437 );
2438 assert!(
2439 worktree
2440 .path()
2441 .join("node_modules/@react-native/typescript-config/tsconfig.json")
2442 .is_file(),
2443 "base worktree should preserve tsconfig extends targets installed in node_modules"
2444 );
2445 }
2446
2447 #[test]
2457 fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
2458 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2459 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2460
2461 let dot_nuxt = host.path().join(".nuxt");
2462 fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
2463 fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
2464 .expect(".nuxt/tsconfig.json should be written");
2465 fs::write(
2466 dot_nuxt.join("tsconfig.app.json"),
2467 r#"{"compilerOptions":{}}"#,
2468 )
2469 .expect(".nuxt/tsconfig.app.json should be written");
2470
2471 materialize_base_dependency_context(host.path(), worktree.path());
2472
2473 let mirrored = worktree.path().join(".nuxt");
2474 assert!(
2475 mirrored.is_dir(),
2476 "base worktree should reuse the ignored .nuxt dir from the host checkout"
2477 );
2478 let link_meta = fs::symlink_metadata(&mirrored)
2479 .expect(".nuxt entry should exist as a symlink in the worktree");
2480 assert!(
2481 link_meta.file_type().is_symlink(),
2482 "base worktree's .nuxt should be a symlink to the host checkout"
2483 );
2484 assert!(
2485 mirrored.join("tsconfig.json").is_file(),
2486 "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
2487 prerequisite check stays quiet"
2488 );
2489 assert!(
2490 mirrored.join("tsconfig.app.json").is_file(),
2491 "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
2492 resolve without falling back to resolver-less resolution"
2493 );
2494 }
2495
2496 #[test]
2501 fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
2502 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2503 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2504
2505 let dot_astro = host.path().join(".astro");
2506 fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
2507 fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
2508 .expect(".astro/types.d.ts should be written");
2509
2510 materialize_base_dependency_context(host.path(), worktree.path());
2511
2512 let mirrored = worktree.path().join(".astro");
2513 assert!(
2514 mirrored.is_dir(),
2515 "base worktree should reuse the ignored .astro dir from the host checkout"
2516 );
2517 assert!(
2518 mirrored.join("types.d.ts").is_file(),
2519 "base worktree should expose generated Astro types so the Astro meta-framework \
2520 prerequisite check stays quiet"
2521 );
2522 }
2523
2524 #[test]
2531 fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
2532 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2533 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2534
2535 materialize_base_dependency_context(host.path(), worktree.path());
2536
2537 assert!(
2538 !worktree.path().join(".nuxt").exists(),
2539 "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
2540 );
2541 assert!(
2542 !worktree.path().join(".astro").exists(),
2543 "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
2544 );
2545 assert!(
2546 !worktree.path().join("node_modules").exists(),
2547 "base worktree should not fabricate a node_modules symlink when the host has none"
2548 );
2549 }
2550
2551 #[test]
2555 fn materialize_base_dependency_context_handles_each_dir_independently() {
2556 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2557 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2558
2559 fs::create_dir_all(host.path().join("node_modules"))
2560 .expect("host node_modules should be created");
2561
2562 materialize_base_dependency_context(host.path(), worktree.path());
2563
2564 assert!(
2565 worktree.path().join("node_modules").is_dir(),
2566 "node_modules should still be symlinked even when host has no .nuxt or .astro"
2567 );
2568 assert!(
2569 !worktree.path().join(".nuxt").exists(),
2570 "missing host .nuxt should leave the worktree slot empty"
2571 );
2572 }
2573
2574 #[test]
2581 fn materialize_base_dependency_context_preserves_real_worktree_dir() {
2582 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2583 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2584
2585 let host_nuxt = host.path().join(".nuxt");
2586 fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
2587 fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
2588 .expect("host .nuxt/tsconfig.json should be written");
2589
2590 let worktree_nuxt = worktree.path().join(".nuxt");
2591 fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
2592 fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
2593 .expect("worktree .nuxt/tsconfig.json should be written");
2594
2595 materialize_base_dependency_context(host.path(), worktree.path());
2596
2597 let link_meta = fs::symlink_metadata(&worktree_nuxt)
2598 .expect(".nuxt entry should still exist in the worktree");
2599 assert!(
2600 !link_meta.file_type().is_symlink(),
2601 "a real base-tracked .nuxt dir must not be replaced by a host symlink"
2602 );
2603 let contents =
2604 fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
2605 assert!(
2606 contents.contains("base"),
2607 "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
2608 );
2609 }
2610
2611 #[test]
2612 fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
2613 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2614 let root = tmp.path();
2615 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2616 .expect("gitignore should be written");
2617 fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
2618 .expect("package.json should be written");
2619
2620 git(root, &["init", "-b", "main"]);
2621 git(root, &["add", "."]);
2622 git(
2623 root,
2624 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2625 );
2626
2627 let rn_config = root.join("node_modules/@react-native/typescript-config");
2628 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2629 fs::write(rn_config.join("tsconfig.json"), "{}")
2630 .expect("node_modules tsconfig should be written");
2631
2632 let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
2633 let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2634 .expect("persistent base worktree should be created");
2635 let worktree_path = first.path().to_path_buf();
2636 assert!(
2637 worktree_path.join("node_modules").is_dir(),
2638 "initial persistent worktree should receive node_modules context"
2639 );
2640 remove_node_modules_context(&worktree_path);
2641 assert!(
2642 !worktree_path.join("node_modules").exists(),
2643 "test setup should remove the dependency context from the reusable worktree"
2644 );
2645 drop(first);
2646
2647 let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2648 .expect("ready persistent base worktree should be reused");
2649 assert_eq!(reused.path(), worktree_path.as_path());
2650 assert!(
2651 reused.path().join("node_modules").is_dir(),
2652 "ready persistent worktree should refresh missing node_modules context"
2653 );
2654
2655 remove_audit_worktree(root, reused.path());
2656 let _ = fs::remove_dir_all(reused.path());
2657 }
2658
2659 fn remove_node_modules_context(worktree_path: &Path) {
2660 let path = worktree_path.join("node_modules");
2661 let Ok(metadata) = fs::symlink_metadata(&path) else {
2662 return;
2663 };
2664 if metadata.file_type().is_symlink() {
2665 #[cfg(unix)]
2666 let _ = fs::remove_file(path);
2667 #[cfg(windows)]
2668 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
2669 } else {
2670 let _ = fs::remove_dir_all(path);
2671 }
2672 }
2673
2674 #[test]
2675 fn audit_base_snapshot_cache_payload_roundtrips_sets() {
2676 let key = AuditBaseSnapshotCacheKey {
2677 hash: 42,
2678 base_sha: "abc123".to_string(),
2679 };
2680 let snapshot = AuditKeySnapshot {
2681 dead_code: ["dead:a".to_string(), "dead:b".to_string()]
2682 .into_iter()
2683 .collect(),
2684 health: std::iter::once("health:a".to_string()).collect(),
2685 dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
2686 .into_iter()
2687 .collect(),
2688 };
2689
2690 let cached = cached_from_snapshot(&key, &snapshot);
2691 assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
2692 assert_eq!(cached.key_hash, key.hash);
2693 assert_eq!(cached.base_sha, key.base_sha);
2694 assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
2695
2696 let decoded = snapshot_from_cached(cached);
2697 assert_eq!(decoded.dead_code, snapshot.dead_code);
2698 assert_eq!(decoded.health, snapshot.health);
2699 assert_eq!(decoded.dupes, snapshot.dupes);
2700 }
2701
2702 #[test]
2703 fn audit_base_snapshot_cache_dir_writes_gitignore() {
2704 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2705 let cache_root = tmp.path().join(".custom-fallow-cache");
2706 let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2707
2708 ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2709
2710 assert_eq!(
2711 fs::read_to_string(cache_dir.join(".gitignore")).expect("gitignore should read"),
2712 "*\n"
2713 );
2714 }
2715
2716 #[test]
2717 fn audit_base_snapshot_cache_roundtrips_from_disk() {
2718 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2719 let config_path = None;
2720 let cache_root = tmp.path().join(".custom-fallow-cache");
2721 let opts = AuditOptions {
2722 root: tmp.path(),
2723 cache_dir: &cache_root,
2724 config_path: &config_path,
2725 output: OutputFormat::Json,
2726 no_cache: false,
2727 threads: 1,
2728 quiet: true,
2729 changed_since: Some("HEAD"),
2730 production: false,
2731 production_dead_code: None,
2732 production_health: None,
2733 production_dupes: None,
2734 workspace: None,
2735 changed_workspaces: None,
2736 explain: false,
2737 explain_skipped: false,
2738 performance: false,
2739 group_by: None,
2740 dead_code_baseline: None,
2741 health_baseline: None,
2742 dupes_baseline: None,
2743 max_crap: None,
2744 coverage: None,
2745 coverage_root: None,
2746 gate: AuditGate::NewOnly,
2747 include_entry_exports: false,
2748 runtime_coverage: None,
2749 min_invocations_hot: 100,
2750 };
2751 let key = AuditBaseSnapshotCacheKey {
2752 hash: 0xfeed,
2753 base_sha: "abc123".to_string(),
2754 };
2755 let snapshot = AuditKeySnapshot {
2756 dead_code: std::iter::once("dead:a".to_string()).collect(),
2757 health: std::iter::once("health:a".to_string()).collect(),
2758 dupes: std::iter::once("dupe:a".to_string()).collect(),
2759 };
2760
2761 save_cached_base_snapshot(&opts, &key, &snapshot);
2762 assert!(
2763 audit_base_snapshot_cache_file(&cache_root, &key).exists(),
2764 "snapshot should be saved below the configured cache directory"
2765 );
2766 let loaded = load_cached_base_snapshot(&opts, &key).expect("snapshot should load");
2767
2768 assert_eq!(loaded.dead_code, snapshot.dead_code);
2769 assert_eq!(loaded.health, snapshot.health);
2770 assert_eq!(loaded.dupes, snapshot.dupes);
2771 }
2772
2773 #[test]
2774 fn audit_base_snapshot_cache_rejects_mismatched_key() {
2775 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2776 let config_path = None;
2777 let cache_root = tmp.path().join(".custom-fallow-cache");
2778 let opts = AuditOptions {
2779 root: tmp.path(),
2780 cache_dir: &cache_root,
2781 config_path: &config_path,
2782 output: OutputFormat::Json,
2783 no_cache: false,
2784 threads: 1,
2785 quiet: true,
2786 changed_since: Some("HEAD"),
2787 production: false,
2788 production_dead_code: None,
2789 production_health: None,
2790 production_dupes: None,
2791 workspace: None,
2792 changed_workspaces: None,
2793 explain: false,
2794 explain_skipped: false,
2795 performance: false,
2796 group_by: None,
2797 dead_code_baseline: None,
2798 health_baseline: None,
2799 dupes_baseline: None,
2800 max_crap: None,
2801 coverage: None,
2802 coverage_root: None,
2803 gate: AuditGate::NewOnly,
2804 include_entry_exports: false,
2805 runtime_coverage: None,
2806 min_invocations_hot: 100,
2807 };
2808 let key = AuditBaseSnapshotCacheKey {
2809 hash: 0xbeef,
2810 base_sha: "head".to_string(),
2811 };
2812 let cached = CachedAuditKeySnapshot {
2813 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
2814 cli_version: env!("CARGO_PKG_VERSION").to_string(),
2815 key_hash: key.hash,
2816 base_sha: "other".to_string(),
2817 dead_code: vec!["dead:a".to_string()],
2818 health: vec![],
2819 dupes: vec![],
2820 };
2821 let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2822 ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2823 fs::write(
2824 audit_base_snapshot_cache_file(&cache_root, &key),
2825 bitcode::encode(&cached),
2826 )
2827 .expect("cache file should be written");
2828
2829 assert!(load_cached_base_snapshot(&opts, &key).is_none());
2830 }
2831
2832 #[test]
2833 fn audit_base_snapshot_cache_key_includes_extended_config() {
2834 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2835 let root = tmp.path();
2836 fs::write(
2837 root.join(".fallowrc.json"),
2838 r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
2839 )
2840 .expect("config should be written");
2841 fs::write(
2842 root.join("base.json"),
2843 r#"{"rules":{"unused-exports":"off"}}"#,
2844 )
2845 .expect("base config should be written");
2846
2847 let config_path = None;
2848 let cache_root = root.join(".fallow");
2849 let opts = AuditOptions {
2850 root,
2851 cache_dir: &cache_root,
2852 config_path: &config_path,
2853 output: OutputFormat::Json,
2854 no_cache: false,
2855 threads: 1,
2856 quiet: true,
2857 changed_since: Some("HEAD"),
2858 production: false,
2859 production_dead_code: None,
2860 production_health: None,
2861 production_dupes: None,
2862 workspace: None,
2863 changed_workspaces: None,
2864 explain: false,
2865 explain_skipped: false,
2866 performance: false,
2867 group_by: None,
2868 dead_code_baseline: None,
2869 health_baseline: None,
2870 dupes_baseline: None,
2871 max_crap: None,
2872 coverage: None,
2873 coverage_root: None,
2874 gate: AuditGate::NewOnly,
2875 include_entry_exports: false,
2876 runtime_coverage: None,
2877 min_invocations_hot: 100,
2878 };
2879
2880 let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
2881 fs::write(
2882 root.join("base.json"),
2883 r#"{"rules":{"unused-exports":"error"}}"#,
2884 )
2885 .expect("base config should be updated");
2886 let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
2887
2888 assert_ne!(
2889 first["resolved_hash"], second["resolved_hash"],
2890 "extended config changes must invalidate cached base snapshots"
2891 );
2892 }
2893
2894 #[test]
2895 fn audit_gate_all_skips_base_snapshot() {
2896 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2897 let root = tmp.path();
2898 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2899 fs::write(
2900 root.join("package.json"),
2901 r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
2902 )
2903 .expect("package.json should be written");
2904 fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
2905 .expect("index should be written");
2906
2907 git(root, &["init", "-b", "main"]);
2908 git(root, &["add", "."]);
2909 git(
2910 root,
2911 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2912 );
2913 fs::write(
2914 root.join("src/index.ts"),
2915 "export const legacy = 1;\nexport const changed = 2;\n",
2916 )
2917 .expect("changed module should be written");
2918
2919 let config_path = None;
2920 let cache_root = root.join(".fallow");
2921 let opts = AuditOptions {
2922 root,
2923 cache_dir: &cache_root,
2924 config_path: &config_path,
2925 output: OutputFormat::Json,
2926 no_cache: true,
2927 threads: 1,
2928 quiet: true,
2929 changed_since: Some("HEAD"),
2930 production: false,
2931 production_dead_code: None,
2932 production_health: None,
2933 production_dupes: None,
2934 workspace: None,
2935 changed_workspaces: None,
2936 explain: false,
2937 explain_skipped: false,
2938 performance: false,
2939 group_by: None,
2940 dead_code_baseline: None,
2941 health_baseline: None,
2942 dupes_baseline: None,
2943 max_crap: None,
2944 coverage: None,
2945 coverage_root: None,
2946 gate: AuditGate::All,
2947 include_entry_exports: false,
2948 runtime_coverage: None,
2949 min_invocations_hot: 100,
2950 };
2951
2952 let result = execute_audit(&opts).expect("audit should execute");
2953 assert!(result.base_snapshot.is_none());
2954 assert_eq!(result.attribution.gate, AuditGate::All);
2955 assert_eq!(result.attribution.dead_code_introduced, 0);
2956 assert_eq!(result.attribution.dead_code_inherited, 0);
2957 }
2958
2959 #[test]
2960 fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
2961 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2962 let root = tmp.path();
2963 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2964 fs::write(
2965 root.join("package.json"),
2966 r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
2967 )
2968 .expect("package.json should be written");
2969 fs::write(
2970 root.join(".fallowrc.json"),
2971 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
2972 )
2973 .expect("config should be written");
2974 let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
2975 fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
2976 fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
2977 fs::write(root.join("README.md"), "before\n").expect("readme should be written");
2978
2979 git(root, &["init", "-b", "main"]);
2980 git(root, &["add", "."]);
2981 git(
2982 root,
2983 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2984 );
2985 fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
2986 fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
2987 .expect("cache dir should be created");
2988 fs::write(
2989 root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
2990 b"cache",
2991 )
2992 .expect("cache artifact should be written");
2993
2994 let before_worktrees = audit_worktree_names(root);
2995
2996 let config_path = None;
2997 let cache_root = root.join(".fallow");
2998 let opts = AuditOptions {
2999 root,
3000 cache_dir: &cache_root,
3001 config_path: &config_path,
3002 output: OutputFormat::Json,
3003 no_cache: true,
3004 threads: 1,
3005 quiet: true,
3006 changed_since: Some("HEAD"),
3007 production: false,
3008 production_dead_code: None,
3009 production_health: None,
3010 production_dupes: None,
3011 workspace: None,
3012 changed_workspaces: None,
3013 explain: false,
3014 explain_skipped: false,
3015 performance: true,
3016 group_by: None,
3017 dead_code_baseline: None,
3018 health_baseline: None,
3019 dupes_baseline: None,
3020 max_crap: None,
3021 coverage: None,
3022 coverage_root: None,
3023 gate: AuditGate::NewOnly,
3024 include_entry_exports: false,
3025 runtime_coverage: None,
3026 min_invocations_hot: 100,
3027 };
3028
3029 let result = execute_audit(&opts).expect("audit should execute");
3030 assert_eq!(result.verdict, AuditVerdict::Pass);
3031 assert_eq!(result.changed_files_count, 2);
3032 assert!(result.base_snapshot_skipped);
3033 assert!(result.base_snapshot.is_some());
3034
3035 let after_worktrees = audit_worktree_names(root);
3036 assert_eq!(
3037 before_worktrees, after_worktrees,
3038 "base snapshot skip must not create a temporary base worktree"
3039 );
3040 }
3041
3042 fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
3043 let mut names: Vec<String> = list_audit_worktrees(repo_root)
3044 .unwrap_or_default()
3045 .into_iter()
3046 .filter_map(|path| {
3047 path.file_name()
3048 .and_then(|name| name.to_str())
3049 .map(str::to_owned)
3050 })
3051 .collect();
3052 names.sort();
3053 names
3054 }
3055
3056 #[test]
3057 fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
3058 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3059 let root = tmp.path();
3060 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3061 fs::write(
3062 root.join("package.json"),
3063 r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
3064 )
3065 .expect("package.json should be written");
3066 fs::write(
3067 root.join("src/index.ts"),
3068 "import { used } from './used';\nused();\n",
3069 )
3070 .expect("index should be written");
3071 fs::write(
3072 root.join("src/used.ts"),
3073 "export function used() {\n return 1;\n}\n",
3074 )
3075 .expect("used module should be written");
3076
3077 git(root, &["init", "-b", "main"]);
3078 git(root, &["add", "."]);
3079 git(
3080 root,
3081 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3082 );
3083 fs::write(
3084 root.join("src/used.ts"),
3085 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
3086 )
3087 .expect("changed module should be written");
3088
3089 let config_path = None;
3090 let cache_root = root.join(".fallow");
3091 let opts = AuditOptions {
3092 root,
3093 cache_dir: &cache_root,
3094 config_path: &config_path,
3095 output: OutputFormat::Json,
3096 no_cache: true,
3097 threads: 1,
3098 quiet: true,
3099 changed_since: Some("HEAD"),
3100 production: false,
3101 production_dead_code: None,
3102 production_health: None,
3103 production_dupes: None,
3104 workspace: None,
3105 changed_workspaces: None,
3106 explain: false,
3107 explain_skipped: false,
3108 performance: true,
3109 group_by: None,
3110 dead_code_baseline: None,
3111 health_baseline: None,
3112 dupes_baseline: None,
3113 max_crap: None,
3114 coverage: None,
3115 coverage_root: None,
3116 gate: AuditGate::NewOnly,
3117 include_entry_exports: false,
3118 runtime_coverage: None,
3119 min_invocations_hot: 100,
3120 };
3121
3122 let result = execute_audit(&opts).expect("audit should execute");
3123 let health = result.health.expect("health should run for changed files");
3124 let timings = health.timings.expect("performance timings should be kept");
3125 assert!(timings.discover_ms.abs() < f64::EPSILON);
3126 assert!(timings.parse_ms.abs() < f64::EPSILON);
3127 assert!(
3128 result.dupes.is_some(),
3129 "dupes should run when changed files exist"
3130 );
3131 }
3132
3133 #[test]
3134 fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
3135 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3136 let root = tmp.path();
3137 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3138 fs::write(
3139 root.join("package.json"),
3140 r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
3141 )
3142 .expect("package.json should be written");
3143 fs::write(
3144 root.join("src/index.ts"),
3145 "import { used } from './used';\nused();\n",
3146 )
3147 .expect("index should be written");
3148 fs::write(
3149 root.join("src/used.ts"),
3150 "export function used() {\n return 1;\n}\n",
3151 )
3152 .expect("used module should be written");
3153
3154 git(root, &["init", "-b", "main"]);
3155 git(root, &["add", "."]);
3156 git(
3157 root,
3158 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3159 );
3160 fs::write(
3161 root.join("src/used.ts"),
3162 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
3163 )
3164 .expect("changed module should be written");
3165
3166 let config_path = None;
3167 let cache_root = root.join(".fallow");
3168 let opts = AuditOptions {
3169 root,
3170 cache_dir: &cache_root,
3171 config_path: &config_path,
3172 output: OutputFormat::Json,
3173 no_cache: true,
3174 threads: 1,
3175 quiet: true,
3176 changed_since: Some("HEAD"),
3177 production: false,
3178 production_dead_code: Some(true),
3179 production_health: Some(false),
3180 production_dupes: Some(false),
3181 workspace: None,
3182 changed_workspaces: None,
3183 explain: false,
3184 explain_skipped: false,
3185 performance: true,
3186 group_by: None,
3187 dead_code_baseline: None,
3188 health_baseline: None,
3189 dupes_baseline: None,
3190 max_crap: None,
3191 coverage: None,
3192 coverage_root: None,
3193 gate: AuditGate::NewOnly,
3194 include_entry_exports: false,
3195 runtime_coverage: None,
3196 min_invocations_hot: 100,
3197 };
3198
3199 let result = execute_audit(&opts).expect("audit should execute");
3200 assert!(result.dupes.is_some(), "dupes should still run");
3201 }
3202
3203 #[cfg(unix)]
3204 #[test]
3205 fn remap_focus_files_does_not_canonicalize_through_symlinks() {
3206 let tmp = tempfile::TempDir::new().expect("temp dir");
3207 let real = tmp.path().join("real");
3208 let link = tmp.path().join("link");
3209 fs::create_dir_all(&real).expect("real dir");
3210 std::os::unix::fs::symlink(&real, &link).expect("symlink");
3211 let canonical = link.canonicalize().expect("canonicalize symlink");
3212 assert_ne!(link, canonical, "symlink should not equal its target");
3213
3214 let from_root = PathBuf::from("/repo");
3215 let mut focus = FxHashSet::default();
3216 focus.insert(from_root.join("src/foo.ts"));
3217
3218 let remapped = remap_focus_files(&focus, &from_root, &link)
3219 .expect("remap should succeed for in-prefix files");
3220
3221 let expected = link.join("src/foo.ts");
3222 assert!(
3223 remapped.contains(&expected),
3224 "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
3225 );
3226 }
3227
3228 #[test]
3229 fn remap_focus_files_skips_paths_outside_from_root() {
3230 let from_root = PathBuf::from("/repo/apps/web");
3231 let to_root = PathBuf::from("/wt/apps/web");
3232 let mut focus = FxHashSet::default();
3233 focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
3234 focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
3235
3236 let remapped =
3237 remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
3238
3239 assert_eq!(remapped.len(), 1);
3240 assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
3241 }
3242
3243 #[test]
3244 fn remap_focus_files_returns_none_when_no_paths_map() {
3245 let from_root = PathBuf::from("/repo/apps/web");
3246 let to_root = PathBuf::from("/wt/apps/web");
3247 let mut focus = FxHashSet::default();
3248 focus.insert(PathBuf::from("/elsewhere/foo.ts"));
3249
3250 let remapped = remap_focus_files(&focus, &from_root, &to_root);
3251 assert!(
3252 remapped.is_none(),
3253 "remap should return None when no paths can be mapped, falling caller back to full corpus"
3254 );
3255 }
3256
3257 #[test]
3258 fn remap_cache_dir_moves_project_local_cache_to_base_worktree() {
3259 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3260 let current_root = tmp.path().join("repo");
3261 let base_root = tmp.path().join("fallow-base");
3262 let cache_dir = current_root.join(".cache").join("fallow");
3263
3264 let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
3265
3266 assert_eq!(remapped, base_root.join(".cache").join("fallow"));
3267 }
3268
3269 #[test]
3270 fn remap_cache_dir_keeps_external_absolute_cache_shared() {
3271 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3272 let current_root = tmp.path().join("repo");
3273 let base_root = tmp.path().join("fallow-base");
3274 let cache_dir = tmp.path().join("shared").join("fallow-cache");
3275
3276 let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
3277
3278 assert_eq!(remapped, cache_dir);
3279 }
3280
3281 #[test]
3282 fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
3283 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3284 let root_buf = tmp
3285 .path()
3286 .canonicalize()
3287 .expect("temp root should canonicalize");
3288 let root = root_buf.as_path();
3289 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3290 fs::write(
3291 root.join("package.json"),
3292 r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
3293 )
3294 .expect("package.json should be written");
3295 fs::write(
3296 root.join(".fallowrc.json"),
3297 r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
3298 )
3299 .expect("config should be written");
3300
3301 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";
3302 fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
3303 fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
3304
3305 git(root, &["init", "-b", "main"]);
3306 git(root, &["add", "."]);
3307 git(
3308 root,
3309 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3310 );
3311 fs::write(
3312 root.join("src/changed.ts"),
3313 format!("{dup_block}// touched\n"),
3314 )
3315 .expect("changed file should be modified");
3316 git(root, &["add", "."]);
3317 git(
3318 root,
3319 &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
3320 );
3321
3322 let config_path = None;
3323 let cache_root = root.join(".fallow");
3324 let opts = AuditOptions {
3325 root,
3326 cache_dir: &cache_root,
3327 config_path: &config_path,
3328 output: OutputFormat::Json,
3329 no_cache: true,
3330 threads: 1,
3331 quiet: true,
3332 changed_since: Some("HEAD~1"),
3333 production: false,
3334 production_dead_code: None,
3335 production_health: None,
3336 production_dupes: None,
3337 workspace: None,
3338 changed_workspaces: None,
3339 explain: false,
3340 explain_skipped: false,
3341 performance: false,
3342 group_by: None,
3343 dead_code_baseline: None,
3344 health_baseline: None,
3345 dupes_baseline: None,
3346 max_crap: None,
3347 coverage: None,
3348 coverage_root: None,
3349 gate: AuditGate::NewOnly,
3350 include_entry_exports: false,
3351 runtime_coverage: None,
3352 min_invocations_hot: 100,
3353 };
3354
3355 let result = execute_audit(&opts).expect("audit should execute");
3356 assert!(
3357 result.base_snapshot_skipped,
3358 "comment-only JS/TS diffs should reuse current keys as the base snapshot"
3359 );
3360 let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
3361 assert!(
3362 !dupes_report.clone_groups.is_empty(),
3363 "current run should detect the pre-existing duplicate"
3364 );
3365 assert_eq!(
3366 result.attribution.duplication_introduced, 0,
3367 "pre-existing duplicate must not be classified as introduced; \
3368 attribution = {:?}",
3369 result.attribution
3370 );
3371 assert!(
3372 result.attribution.duplication_inherited > 0,
3373 "pre-existing duplicate must be classified as inherited; \
3374 attribution = {:?}",
3375 result.attribution
3376 );
3377 }
3378
3379 #[test]
3380 fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
3381 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3382 let root = tmp.path();
3383 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3384 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3385 .expect("node_modules config dir should be created");
3386 fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
3387 fs::write(
3388 root.join("package.json"),
3389 r#"{
3390 "name": "audit-react-native-tsconfig-base",
3391 "private": true,
3392 "main": "src/App.tsx",
3393 "dependencies": {
3394 "react-native": "0.80.0"
3395 }
3396 }"#,
3397 )
3398 .expect("package.json should be written");
3399 fs::write(
3400 root.join("tsconfig.json"),
3401 r#"{
3402 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3403 "compilerOptions": {
3404 "baseUrl": ".",
3405 "paths": {
3406 "@/*": ["src/*"]
3407 }
3408 },
3409 "include": ["src/**/*"]
3410 }"#,
3411 )
3412 .expect("tsconfig should be written");
3413 fs::write(
3414 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3415 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3416 )
3417 .expect("react native tsconfig should be written");
3418 fs::write(
3419 root.join("src/App.tsx"),
3420 r#"import { homeTitle } from "@/screens/Home";
3421
3422export function App() {
3423 return homeTitle;
3424}
3425"#,
3426 )
3427 .expect("app should be written");
3428 fs::write(
3429 root.join("src/screens/Home.ts"),
3430 r#"export const homeTitle = "home";
3431"#,
3432 )
3433 .expect("home should be written");
3434
3435 git(root, &["init", "-b", "main"]);
3436 git(root, &["add", "."]);
3437 git(
3438 root,
3439 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3440 );
3441 fs::write(
3442 root.join("src/App.tsx"),
3443 r#"import { homeTitle } from "@/screens/Home";
3444
3445export function App() {
3446 return homeTitle.toUpperCase();
3447}
3448"#,
3449 )
3450 .expect("app should be modified");
3451
3452 let config_path = None;
3453 let cache_root = root.join(".fallow");
3454 let opts = AuditOptions {
3455 root,
3456 cache_dir: &cache_root,
3457 config_path: &config_path,
3458 output: OutputFormat::Json,
3459 no_cache: true,
3460 threads: 1,
3461 quiet: true,
3462 changed_since: Some("HEAD"),
3463 production: false,
3464 production_dead_code: None,
3465 production_health: None,
3466 production_dupes: None,
3467 workspace: None,
3468 changed_workspaces: None,
3469 explain: false,
3470 explain_skipped: false,
3471 performance: false,
3472 group_by: None,
3473 dead_code_baseline: None,
3474 health_baseline: None,
3475 dupes_baseline: None,
3476 max_crap: None,
3477 coverage: None,
3478 coverage_root: None,
3479 gate: AuditGate::NewOnly,
3480 include_entry_exports: false,
3481 runtime_coverage: None,
3482 min_invocations_hot: 100,
3483 };
3484
3485 let result = execute_audit(&opts).expect("audit should execute");
3486 assert!(
3487 !result.base_snapshot_skipped,
3488 "source diffs should run a real base snapshot"
3489 );
3490 let base = result
3491 .base_snapshot
3492 .as_ref()
3493 .expect("base snapshot should run");
3494 assert!(
3495 !base
3496 .dead_code
3497 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3498 "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
3499 base.dead_code
3500 );
3501 assert!(
3502 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3503 "alias target should stay reachable in the base worktree: {:?}",
3504 base.dead_code
3505 );
3506 let check = result.check.as_ref().expect("dead-code audit should run");
3507 assert!(
3508 check.results.unresolved_imports.is_empty(),
3509 "HEAD audit should also resolve @/* aliases: {:?}",
3510 check.results.unresolved_imports
3511 );
3512 }
3513
3514 #[test]
3515 fn audit_base_preserves_subdirectory_root_resolution() {
3516 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3517 let repo = tmp.path().join("repo");
3518 let root = repo.join("apps/mobile");
3519 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3520 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3521 .expect("node_modules config dir should be created");
3522 fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
3523 .expect("gitignore should be written");
3524 fs::write(
3525 root.join("package.json"),
3526 r#"{
3527 "name": "audit-subdir-react-native-tsconfig-base",
3528 "private": true,
3529 "main": "src/App.tsx",
3530 "dependencies": {
3531 "react-native": "0.80.0"
3532 }
3533 }"#,
3534 )
3535 .expect("package.json should be written");
3536 fs::write(
3537 root.join("tsconfig.json"),
3538 r#"{
3539 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3540 "compilerOptions": {
3541 "baseUrl": ".",
3542 "paths": {
3543 "@/*": ["src/*"]
3544 }
3545 },
3546 "include": ["src/**/*"]
3547 }"#,
3548 )
3549 .expect("tsconfig should be written");
3550 fs::write(
3551 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3552 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3553 )
3554 .expect("react native tsconfig should be written");
3555 fs::write(
3556 root.join("src/App.tsx"),
3557 r#"import { homeTitle } from "@/screens/Home";
3558
3559export function App() {
3560 return homeTitle;
3561}
3562"#,
3563 )
3564 .expect("app should be written");
3565 fs::write(
3566 root.join("src/screens/Home.ts"),
3567 r#"export const homeTitle = "home";
3568"#,
3569 )
3570 .expect("home should be written");
3571
3572 git(&repo, &["init", "-b", "main"]);
3573 git(&repo, &["add", "."]);
3574 git(
3575 &repo,
3576 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3577 );
3578 fs::write(
3579 root.join("src/App.tsx"),
3580 r#"import { homeTitle } from "@/screens/Home";
3581
3582export function App() {
3583 return homeTitle.toUpperCase();
3584}
3585"#,
3586 )
3587 .expect("app should be modified");
3588
3589 let config_path = None;
3590 let cache_root = root.join(".fallow");
3591 let opts = AuditOptions {
3592 root: &root,
3593 cache_dir: &cache_root,
3594 config_path: &config_path,
3595 output: OutputFormat::Json,
3596 no_cache: true,
3597 threads: 1,
3598 quiet: true,
3599 changed_since: Some("HEAD"),
3600 production: false,
3601 production_dead_code: None,
3602 production_health: None,
3603 production_dupes: None,
3604 workspace: None,
3605 changed_workspaces: None,
3606 explain: false,
3607 explain_skipped: false,
3608 performance: false,
3609 group_by: None,
3610 dead_code_baseline: None,
3611 health_baseline: None,
3612 dupes_baseline: None,
3613 max_crap: None,
3614 coverage: None,
3615 coverage_root: None,
3616 gate: AuditGate::NewOnly,
3617 include_entry_exports: false,
3618 runtime_coverage: None,
3619 min_invocations_hot: 100,
3620 };
3621
3622 let result = execute_audit(&opts).expect("audit should execute");
3623 assert!(
3624 !result.base_snapshot_skipped,
3625 "source diffs should run a real base snapshot"
3626 );
3627 let base = result
3628 .base_snapshot
3629 .as_ref()
3630 .expect("base snapshot should run");
3631 assert!(
3632 !base
3633 .dead_code
3634 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3635 "base audit should analyze from the app subdirectory, not the repo root: {:?}",
3636 base.dead_code
3637 );
3638 assert!(
3639 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3640 "subdirectory base audit should keep alias targets reachable: {:?}",
3641 base.dead_code
3642 );
3643 }
3644
3645 #[test]
3646 fn audit_base_uses_new_explicit_config_without_hard_failure() {
3647 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3648 let root = tmp.path();
3649 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3650 fs::write(
3651 root.join("package.json"),
3652 r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
3653 )
3654 .expect("package.json should be written");
3655 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3656 .expect("index should be written");
3657
3658 git(root, &["init", "-b", "main"]);
3659 git(root, &["add", "."]);
3660 git(
3661 root,
3662 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3663 );
3664
3665 let explicit_config = root.join(".fallowrc.json");
3666 fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
3667 .expect("new config should be written");
3668 fs::write(root.join("src/index.ts"), "export const used = 2;\n")
3669 .expect("index should be modified");
3670
3671 let config_path = Some(explicit_config);
3672 let cache_root = root.join(".fallow");
3673 let opts = AuditOptions {
3674 root,
3675 cache_dir: &cache_root,
3676 config_path: &config_path,
3677 output: OutputFormat::Json,
3678 no_cache: true,
3679 threads: 1,
3680 quiet: true,
3681 changed_since: Some("HEAD"),
3682 production: false,
3683 production_dead_code: None,
3684 production_health: None,
3685 production_dupes: None,
3686 workspace: None,
3687 changed_workspaces: None,
3688 explain: false,
3689 explain_skipped: false,
3690 performance: false,
3691 group_by: None,
3692 dead_code_baseline: None,
3693 health_baseline: None,
3694 dupes_baseline: None,
3695 max_crap: None,
3696 coverage: None,
3697 coverage_root: None,
3698 gate: AuditGate::NewOnly,
3699 include_entry_exports: false,
3700 runtime_coverage: None,
3701 min_invocations_hot: 100,
3702 };
3703
3704 let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
3705 assert!(
3706 result.base_snapshot.is_some(),
3707 "base snapshot should use the current explicit config even when the base commit lacks it"
3708 );
3709 }
3710
3711 #[test]
3712 fn audit_base_uses_current_discovered_config_for_attribution() {
3713 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3714 let root = tmp.path();
3715 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3716 fs::write(
3717 root.join("package.json"),
3718 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3719 )
3720 .expect("package.json should be written");
3721 fs::write(
3722 root.join(".fallowrc.json"),
3723 r#"{"rules":{"unused-dependencies":"off"}}"#,
3724 )
3725 .expect("base config should be written");
3726 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3727 .expect("index should be written");
3728
3729 git(root, &["init", "-b", "main"]);
3730 git(root, &["add", "."]);
3731 git(
3732 root,
3733 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3734 );
3735
3736 fs::write(
3737 root.join(".fallowrc.json"),
3738 r#"{"rules":{"unused-dependencies":"error"}}"#,
3739 )
3740 .expect("current config should be written");
3741 fs::write(
3742 root.join("package.json"),
3743 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3744 )
3745 .expect("package.json should be touched");
3746
3747 let config_path = None;
3748 let cache_root = root.join(".fallow");
3749 let opts = AuditOptions {
3750 root,
3751 cache_dir: &cache_root,
3752 config_path: &config_path,
3753 output: OutputFormat::Json,
3754 no_cache: true,
3755 threads: 1,
3756 quiet: true,
3757 changed_since: Some("HEAD"),
3758 production: false,
3759 production_dead_code: None,
3760 production_health: None,
3761 production_dupes: None,
3762 workspace: None,
3763 changed_workspaces: None,
3764 explain: false,
3765 explain_skipped: false,
3766 performance: false,
3767 group_by: None,
3768 dead_code_baseline: None,
3769 health_baseline: None,
3770 dupes_baseline: None,
3771 max_crap: None,
3772 coverage: None,
3773 coverage_root: None,
3774 gate: AuditGate::NewOnly,
3775 include_entry_exports: false,
3776 runtime_coverage: None,
3777 min_invocations_hot: 100,
3778 };
3779
3780 let result = execute_audit(&opts).expect("audit should execute");
3781 assert_eq!(
3782 result.attribution.dead_code_introduced, 0,
3783 "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
3784 result.attribution
3785 );
3786 assert!(
3787 result.attribution.dead_code_inherited > 0,
3788 "pre-existing changed-file findings should be classified as inherited: {:?}",
3789 result.attribution
3790 );
3791 }
3792
3793 #[test]
3794 fn audit_base_current_config_attribution_survives_cache_hit() {
3795 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3796 let root = tmp.path();
3797 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3798 fs::write(
3799 root.join("package.json"),
3800 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3801 )
3802 .expect("package.json should be written");
3803 fs::write(
3804 root.join(".fallowrc.json"),
3805 r#"{"rules":{"unused-dependencies":"off"}}"#,
3806 )
3807 .expect("base config should be written");
3808 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3809 .expect("index should be written");
3810
3811 git(root, &["init", "-b", "main"]);
3812 git(root, &["add", "."]);
3813 git(
3814 root,
3815 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3816 );
3817
3818 fs::write(
3819 root.join(".fallowrc.json"),
3820 r#"{"rules":{"unused-dependencies":"error"}}"#,
3821 )
3822 .expect("current config should be written");
3823 fs::write(
3824 root.join("package.json"),
3825 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3826 )
3827 .expect("package.json should be touched");
3828
3829 let config_path = None;
3830 let cache_root = root.join(".fallow");
3831 let opts = AuditOptions {
3832 root,
3833 cache_dir: &cache_root,
3834 config_path: &config_path,
3835 output: OutputFormat::Json,
3836 no_cache: false,
3837 threads: 1,
3838 quiet: true,
3839 changed_since: Some("HEAD"),
3840 production: false,
3841 production_dead_code: None,
3842 production_health: None,
3843 production_dupes: None,
3844 workspace: None,
3845 changed_workspaces: None,
3846 explain: false,
3847 explain_skipped: false,
3848 performance: false,
3849 group_by: None,
3850 dead_code_baseline: None,
3851 health_baseline: None,
3852 dupes_baseline: None,
3853 max_crap: None,
3854 coverage: None,
3855 coverage_root: None,
3856 gate: AuditGate::NewOnly,
3857 include_entry_exports: false,
3858 runtime_coverage: None,
3859 min_invocations_hot: 100,
3860 };
3861
3862 let first = execute_audit(&opts).expect("first audit should execute");
3863 assert_eq!(
3864 first.attribution.dead_code_introduced, 0,
3865 "first audit should classify pre-existing findings as inherited: {:?}",
3866 first.attribution
3867 );
3868
3869 let changed_files =
3870 crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
3871 let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
3872 .expect("cache key should compute")
3873 .expect("cache key should exist");
3874 assert!(
3875 load_cached_base_snapshot(&opts, &key).is_some(),
3876 "first audit should store a reusable base snapshot"
3877 );
3878
3879 let second = execute_audit(&opts).expect("second audit should execute");
3880 assert_eq!(
3881 second.attribution.dead_code_introduced, 0,
3882 "cache hit should keep current-config attribution stable: {:?}",
3883 second.attribution
3884 );
3885 assert!(
3886 second.attribution.dead_code_inherited > 0,
3887 "cache hit should preserve inherited base findings: {:?}",
3888 second.attribution
3889 );
3890 }
3891
3892 #[test]
3893 fn audit_dupes_only_materializes_groups_touching_changed_files() {
3894 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3895 let root_path = tmp
3896 .path()
3897 .canonicalize()
3898 .expect("temp root should canonicalize");
3899 let root = root_path.as_path();
3900 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3901 fs::write(
3902 root.join("package.json"),
3903 r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
3904 )
3905 .expect("package.json should be written");
3906 fs::write(
3907 root.join(".fallowrc.json"),
3908 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
3909 )
3910 .expect("config should be written");
3911
3912 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";
3913 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";
3914 fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
3915 fs::write(root.join("src/focused-copy.ts"), focused_code)
3916 .expect("focused copy should be written");
3917 fs::write(root.join("src/untouched-a.ts"), untouched_code)
3918 .expect("untouched a should be written");
3919 fs::write(root.join("src/untouched-b.ts"), untouched_code)
3920 .expect("untouched b should be written");
3921
3922 git(root, &["init", "-b", "main"]);
3923 git(root, &["add", "."]);
3924 git(
3925 root,
3926 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3927 );
3928 fs::write(
3929 root.join("src/changed.ts"),
3930 format!("{focused_code}export const changedMarker = true;\n"),
3931 )
3932 .expect("changed file should be modified");
3933
3934 let config_path = None;
3935 let cache_root = root.join(".fallow");
3936 let opts = AuditOptions {
3937 root,
3938 cache_dir: &cache_root,
3939 config_path: &config_path,
3940 output: OutputFormat::Json,
3941 no_cache: true,
3942 threads: 1,
3943 quiet: true,
3944 changed_since: Some("HEAD"),
3945 production: false,
3946 production_dead_code: None,
3947 production_health: None,
3948 production_dupes: None,
3949 workspace: None,
3950 changed_workspaces: None,
3951 explain: false,
3952 explain_skipped: false,
3953 performance: false,
3954 group_by: None,
3955 dead_code_baseline: None,
3956 health_baseline: None,
3957 dupes_baseline: None,
3958 max_crap: None,
3959 coverage: None,
3960 coverage_root: None,
3961 gate: AuditGate::All,
3962 include_entry_exports: false,
3963 runtime_coverage: None,
3964 min_invocations_hot: 100,
3965 };
3966
3967 let result = execute_audit(&opts).expect("audit should execute");
3968 let dupes = result.dupes.expect("dupes should run");
3969 let changed_path = root.join("src/changed.ts");
3970
3971 assert!(
3972 !dupes.report.clone_groups.is_empty(),
3973 "changed file should still match unchanged duplicate code"
3974 );
3975 assert!(dupes.report.clone_groups.iter().all(|group| {
3976 group
3977 .instances
3978 .iter()
3979 .any(|instance| instance.file == changed_path)
3980 }));
3981 }
3982
3983 #[test]
3986 fn tokens_equivalent_whitespace_only() {
3987 let a = "export const x = 1;\nexport const y = 2;\n";
3989 let b = "export const x = 1;\n\n\nexport const y = 2;\n";
3990 assert!(
3991 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
3992 "whitespace-only change must be treated as equivalent"
3993 );
3994 }
3995
3996 #[test]
3997 fn tokens_equivalent_comment_only_change() {
3998 let a = "export const x = 1;\n";
4001 let b = "// note\nexport const x = 1;\n";
4002 assert!(
4003 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4004 "comment-only change must be treated as equivalent (comments emit no tokens)"
4005 );
4006 }
4007
4008 #[test]
4009 fn tokens_equivalent_identifier_rename_is_not_equivalent() {
4010 let a = "export const a = 1;\n";
4012 let b = "export const b = 1;\n";
4013 assert!(
4014 !js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4015 "identifier rename must be treated as non-equivalent"
4016 );
4017 }
4018
4019 #[test]
4020 fn tokens_equivalent_string_literal_change_is_not_equivalent() {
4021 let a = r#"import x from "./a";"#;
4023 let b = r#"import x from "./b";"#;
4024 assert!(
4025 !js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4026 "string-literal change must be treated as non-equivalent"
4027 );
4028 }
4029
4030 #[test]
4031 fn tokens_equivalent_fallow_ignore_marker_forces_false() {
4032 let code = "// fallow-ignore-next-line unused-exports\nexport const x = 1;\n";
4035 assert!(
4036 !js_ts_tokens_equivalent(Path::new("a.ts"), code, code),
4037 "fallow-ignore marker in either side must force false"
4038 );
4039 }
4040
4041 #[test]
4042 fn tokens_equivalent_non_js_extension_is_false() {
4043 let a = ".foo { color: red; }\n";
4045 let b = ".foo {\n color: red;\n}\n";
4046 assert!(
4047 !js_ts_tokens_equivalent(Path::new("styles.css"), a, b),
4048 "non-JS/TS extension must always return false"
4049 );
4050 }
4051
4052 #[test]
4061 fn tokens_equivalent_template_literal_content_change_is_equivalent_known_gap() {
4062 let a = "const p = import(`./pages/${x}`);\n";
4063 let b = "const p = import(`./views/${x}`);\n";
4064 assert!(
4069 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4070 "template-literal content change is CURRENTLY treated as equivalent (known gap)"
4071 );
4072 }
4073
4074 #[test]
4077 fn tokens_equivalent_regex_literal_content_change_is_equivalent_known_gap() {
4078 let a = "const re = /^foo/;\n";
4079 let b = "const re = /^bar/;\n";
4080 assert!(
4082 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4083 "regex-literal content change is CURRENTLY treated as equivalent (known gap)"
4084 );
4085 }
4086
4087 #[test]
4088 fn analysis_input_and_doc_classification() {
4089 assert!(is_analysis_input(Path::new("src/app.ts")));
4091 assert!(is_analysis_input(Path::new("src/app.tsx")));
4092 assert!(is_analysis_input(Path::new("src/app.js")));
4093 assert!(is_analysis_input(Path::new("src/app.jsx")));
4094 assert!(is_analysis_input(Path::new("src/app.mts")));
4095 assert!(is_analysis_input(Path::new("src/app.vue")));
4096 assert!(is_analysis_input(Path::new("src/styles.css")));
4097
4098 assert!(!is_analysis_input(Path::new("README.md")));
4100 assert!(!is_analysis_input(Path::new("package.json")));
4101 assert!(!is_analysis_input(Path::new("image.png")));
4102
4103 assert!(is_non_behavioral_doc(Path::new("README.md")));
4105 assert!(is_non_behavioral_doc(Path::new("CHANGELOG.txt")));
4106 assert!(is_non_behavioral_doc(Path::new("docs/guide.rst")));
4107 assert!(is_non_behavioral_doc(Path::new("docs/guide.adoc")));
4108
4109 assert!(!is_analysis_input(Path::new("package.json")));
4112 assert!(!is_non_behavioral_doc(Path::new("package.json")));
4113 }
4114}