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