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
1094 Ok(AuditResult {
1095 verdict,
1096 summary,
1097 attribution,
1098 base_snapshot,
1099 base_snapshot_skipped,
1100 changed_files_count,
1101 changed_files: changed_files.into_iter().collect(),
1102 base_ref,
1103 head_sha: get_head_sha(opts.root),
1104 output: opts.output,
1105 performance: opts.performance,
1106 check: check_result,
1107 dupes: dupes_result,
1108 health: health_result,
1109 elapsed: start.elapsed(),
1110 })
1111}
1112
1113fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<String, ExitCode> {
1115 if let Some(ref_str) = opts.changed_since {
1116 return Ok(ref_str.to_string());
1117 }
1118 let Some(branch) = auto_detect_base_branch(opts.root) else {
1119 return Err(emit_error(
1120 "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
1121 2,
1122 opts.output,
1123 ));
1124 };
1125 if let Err(e) = crate::validate::validate_git_ref(&branch) {
1126 return Err(emit_error(
1127 &format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"),
1128 2,
1129 opts.output,
1130 ));
1131 }
1132 Ok(branch)
1133}
1134
1135fn empty_audit_result(base_ref: String, opts: &AuditOptions<'_>, elapsed: Duration) -> AuditResult {
1137 AuditResult {
1138 verdict: AuditVerdict::Pass,
1139 summary: AuditSummary {
1140 dead_code_issues: 0,
1141 dead_code_has_errors: false,
1142 complexity_findings: 0,
1143 max_cyclomatic: None,
1144 duplication_clone_groups: 0,
1145 },
1146 attribution: AuditAttribution {
1147 gate: opts.gate,
1148 ..AuditAttribution::default()
1149 },
1150 base_snapshot: None,
1151 base_snapshot_skipped: false,
1152 changed_files_count: 0,
1153 changed_files: Vec::new(),
1154 base_ref,
1155 head_sha: get_head_sha(opts.root),
1156 output: opts.output,
1157 performance: opts.performance,
1158 check: None,
1159 dupes: None,
1160 health: None,
1161 elapsed,
1162 }
1163}
1164
1165fn run_audit_check<'a>(
1167 opts: &'a AuditOptions<'a>,
1168 changed_since: Option<&'a str>,
1169 retain_modules_for_health: bool,
1170) -> Result<Option<CheckResult>, ExitCode> {
1171 let filters = IssueFilters::default();
1172 let trace_opts = TraceOptions {
1173 trace_export: None,
1174 trace_file: None,
1175 trace_dependency: None,
1176 performance: opts.performance,
1177 };
1178 match crate::check::execute_check(&CheckOptions {
1179 root: opts.root,
1180 config_path: opts.config_path,
1181 output: opts.output,
1182 no_cache: opts.no_cache,
1183 threads: opts.threads,
1184 quiet: opts.quiet,
1185 fail_on_issues: false,
1186 filters: &filters,
1187 changed_since,
1188 diff_index: None,
1189 use_shared_diff_index: true,
1190 baseline: opts.dead_code_baseline,
1191 save_baseline: None,
1192 sarif_file: None,
1193 production: opts.production_dead_code.unwrap_or(opts.production),
1194 production_override: opts.production_dead_code,
1195 workspace: opts.workspace,
1196 changed_workspaces: opts.changed_workspaces,
1197 group_by: opts.group_by,
1198 include_dupes: false,
1199 trace_opts: &trace_opts,
1200 explain: opts.explain,
1201 top: None,
1202 file: &[],
1203 include_entry_exports: opts.include_entry_exports,
1204 summary: false,
1205 regression_opts: crate::regression::RegressionOpts {
1206 fail_on_regression: false,
1207 tolerance: crate::regression::Tolerance::Absolute(0),
1208 regression_baseline_file: None,
1209 save_target: crate::regression::SaveRegressionTarget::None,
1210 scoped: true,
1211 quiet: opts.quiet,
1212 output: opts.output,
1213 },
1214 retain_modules_for_health,
1215 defer_performance: false,
1216 }) {
1217 Ok(r) => Ok(Some(r)),
1218 Err(code) => Err(code),
1219 }
1220}
1221
1222fn run_audit_dupes<'a>(
1228 opts: &'a AuditOptions<'a>,
1229 changed_since: Option<&'a str>,
1230 changed_files: Option<&'a FxHashSet<PathBuf>>,
1231 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1232) -> Result<Option<DupesResult>, ExitCode> {
1233 let dupes_cfg = match crate::load_config_for_analysis(
1234 opts.root,
1235 opts.config_path,
1236 opts.output,
1237 opts.no_cache,
1238 opts.threads,
1239 opts.production_dupes
1240 .or_else(|| opts.production.then_some(true)),
1241 opts.quiet,
1242 fallow_config::ProductionAnalysis::Dupes,
1243 ) {
1244 Ok(c) => c.duplicates,
1245 Err(code) => return Err(code),
1246 };
1247 let dupes_opts = DupesOptions {
1248 root: opts.root,
1249 config_path: opts.config_path,
1250 output: opts.output,
1251 no_cache: opts.no_cache,
1252 threads: opts.threads,
1253 quiet: opts.quiet,
1254 mode: Some(DupesMode::from(dupes_cfg.mode)),
1255 min_tokens: Some(dupes_cfg.min_tokens),
1256 min_lines: Some(dupes_cfg.min_lines),
1257 min_occurrences: Some(dupes_cfg.min_occurrences),
1258 threshold: Some(dupes_cfg.threshold),
1259 skip_local: dupes_cfg.skip_local,
1260 cross_language: dupes_cfg.cross_language,
1261 ignore_imports: dupes_cfg.ignore_imports,
1262 top: None,
1263 baseline_path: opts.dupes_baseline,
1264 save_baseline_path: None,
1265 production: opts.production_dupes.unwrap_or(opts.production),
1266 production_override: opts.production_dupes,
1267 trace: None,
1268 changed_since,
1269 diff_index: None,
1270 use_shared_diff_index: true,
1271 changed_files,
1272 workspace: opts.workspace,
1273 changed_workspaces: opts.changed_workspaces,
1274 explain: opts.explain,
1275 explain_skipped: opts.explain_skipped,
1276 summary: false,
1277 group_by: opts.group_by,
1278 performance: false,
1279 };
1280 let dupes_run = if let Some(files) = pre_discovered {
1281 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1282 } else {
1283 crate::dupes::execute_dupes(&dupes_opts)
1284 };
1285 match dupes_run {
1286 Ok(r) => Ok(Some(r)),
1287 Err(code) => Err(code),
1288 }
1289}
1290
1291fn run_audit_health<'a>(
1293 opts: &'a AuditOptions<'a>,
1294 changed_since: Option<&'a str>,
1295 shared_parse: Option<crate::health::SharedParseData>,
1296) -> Result<Option<HealthResult>, ExitCode> {
1297 let runtime_coverage = match opts.runtime_coverage {
1298 Some(path) => match crate::health::coverage::prepare_options(
1299 path,
1300 opts.min_invocations_hot,
1301 None,
1302 None,
1303 opts.output,
1304 ) {
1305 Ok(options) => Some(options),
1306 Err(code) => return Err(code),
1307 },
1308 None => None,
1309 };
1310
1311 let health_opts = HealthOptions {
1312 root: opts.root,
1313 config_path: opts.config_path,
1314 output: opts.output,
1315 no_cache: opts.no_cache,
1316 threads: opts.threads,
1317 quiet: opts.quiet,
1318 max_cyclomatic: None,
1319 max_cognitive: None,
1320 max_crap: opts.max_crap,
1321 top: None,
1322 sort: SortBy::Cyclomatic,
1323 production: opts.production_health.unwrap_or(opts.production),
1324 production_override: opts.production_health,
1325 changed_since,
1326 diff_index: None,
1327 use_shared_diff_index: true,
1328 workspace: opts.workspace,
1329 changed_workspaces: opts.changed_workspaces,
1330 baseline: opts.health_baseline,
1331 save_baseline: None,
1332 complexity: true,
1333 complexity_breakdown: false,
1334 file_scores: false,
1335 coverage_gaps: false,
1336 config_activates_coverage_gaps: false,
1337 hotspots: false,
1338 ownership: false,
1339 ownership_emails: None,
1340 targets: false,
1341 force_full: false,
1342 score_only_output: false,
1343 enforce_coverage_gap_gate: false,
1344 effort: None,
1345 score: false,
1346 min_score: None,
1347 since: None,
1348 min_commits: None,
1349 explain: opts.explain,
1350 summary: false,
1351 save_snapshot: None,
1352 trend: false,
1353 group_by: opts.group_by,
1354 coverage: opts.coverage,
1355 coverage_root: opts.coverage_root,
1356 performance: opts.performance,
1357 min_severity: None,
1358 report_only: false,
1359 runtime_coverage,
1360 churn_file: None,
1362 };
1363 let health_run = if let Some(shared) = shared_parse {
1364 crate::health::execute_health_with_shared_parse(&health_opts, shared)
1365 } else {
1366 crate::health::execute_health(&health_opts)
1367 };
1368 match health_run {
1369 Ok(r) => Ok(Some(r)),
1370 Err(code) => Err(code),
1371 }
1372}
1373
1374#[path = "audit_output.rs"]
1375mod output;
1376
1377pub use output::print_audit_result;
1378
1379pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
1385 if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
1386 return emit_error(&e, 2, opts.output);
1387 }
1388 let coverage_resolved = opts
1389 .coverage
1390 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1391 let runtime_coverage_resolved = opts
1392 .runtime_coverage
1393 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1394 let resolved_opts = AuditOptions {
1395 coverage: coverage_resolved.as_deref(),
1396 runtime_coverage: runtime_coverage_resolved.as_deref(),
1397 ..*opts
1398 };
1399 match execute_audit(&resolved_opts) {
1400 Ok(result) => {
1401 let mut findings = result
1402 .check
1403 .as_ref()
1404 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
1405 .unwrap_or_default();
1406 if let Some(health) = result.health.as_ref() {
1407 findings.extend(crate::impact::collect_complexity_findings(&health.report));
1408 }
1409 let clones = result
1410 .dupes
1411 .as_ref()
1412 .map(|d| crate::impact::collect_clone_findings(&d.report))
1413 .unwrap_or_default();
1414 let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
1415 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
1416 c.results.active_suppressions.as_slice()
1417 });
1418 let attribution = crate::impact::AttributionInput {
1419 root: opts.root,
1420 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
1421 findings,
1422 clones,
1423 suppressions,
1424 };
1425 crate::impact::record_audit_run(
1426 opts.root,
1427 &result.summary,
1428 &crate::impact::AuditRunRecord {
1429 verdict: result.verdict,
1430 gate: gate_marker.is_some(),
1431 git_sha: result.head_sha.as_deref(),
1432 version: env!("CARGO_PKG_VERSION"),
1433 timestamp: &crate::vital_signs::chrono_timestamp(),
1434 attribution: Some(&attribution),
1435 },
1436 );
1437 print_audit_result(&result, opts.quiet, opts.explain)
1438 }
1439 Err(code) => code,
1440 }
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445 use super::*;
1446 use std::{fs, process::Command};
1447
1448 fn git(dir: &std::path::Path, args: &[&str]) {
1449 let output = Command::new("git")
1450 .args(args)
1451 .current_dir(dir)
1452 .env_remove("GIT_DIR")
1453 .env_remove("GIT_WORK_TREE")
1454 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1455 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1456 .env("GIT_AUTHOR_NAME", "test")
1457 .env("GIT_AUTHOR_EMAIL", "test@test.com")
1458 .env("GIT_COMMITTER_NAME", "test")
1459 .env("GIT_COMMITTER_EMAIL", "test@test.com")
1460 .output()
1461 .expect("git command failed");
1462 assert!(
1463 output.status.success(),
1464 "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
1465 args,
1466 String::from_utf8_lossy(&output.stdout),
1467 String::from_utf8_lossy(&output.stderr)
1468 );
1469 }
1470
1471 #[test]
1472 fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
1473 let temp = std::env::temp_dir();
1474 let audit_path = temp.join("fallow-audit-base-123-456");
1475 let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
1476 let canonical_audit_path = temp
1477 .canonicalize()
1478 .unwrap_or_else(|_| temp.clone())
1479 .join("fallow-audit-base-456-789");
1480 let unrelated_temp = temp.join("other-worktree");
1481 let output = format!(
1482 "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
1483 audit_path.display(),
1484 unrelated_temp.display(),
1485 reusable_path.display()
1486 );
1487
1488 assert_eq!(
1489 parse_worktree_list(&output),
1490 vec![audit_path, reusable_path.clone()]
1491 );
1492 assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
1493 assert!(is_reusable_audit_worktree_path(&reusable_path));
1494 assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
1495 assert_eq!(
1496 audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
1497 None
1498 );
1499 assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
1500 }
1501
1502 fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
1506 let root = parent.join(name);
1507 fs::create_dir_all(&root).expect("repo root should be created");
1508 fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
1509 git(&root, &["init", "-b", "main"]);
1510 git(&root, &["add", "."]);
1511 git(
1512 &root,
1513 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1514 );
1515 root
1516 }
1517
1518 #[test]
1519 fn auto_detect_base_branch_prefers_origin_head() {
1520 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1521 let repo = init_throwaway_repo(tmp.path(), "repo");
1522 git(&repo, &["branch", "trunk"]);
1523 git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
1524 git(
1525 &repo,
1526 &[
1527 "symbolic-ref",
1528 "refs/remotes/origin/HEAD",
1529 "refs/remotes/origin/trunk",
1530 ],
1531 );
1532
1533 assert_eq!(auto_detect_base_branch(&repo), Some("trunk".to_string()));
1534 }
1535
1536 #[test]
1537 fn auto_detect_base_branch_falls_back_to_main() {
1538 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1539 let repo = init_throwaway_repo(tmp.path(), "repo");
1540
1541 assert_eq!(auto_detect_base_branch(&repo), Some("main".to_string()));
1542 }
1543
1544 #[test]
1545 fn auto_detect_base_branch_falls_back_to_master() {
1546 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1547 let repo = tmp.path().join("repo");
1548 fs::create_dir_all(&repo).expect("repo root should be created");
1549 fs::write(repo.join("README.md"), "seed\n").expect("seed file should be written");
1550 git(&repo, &["init", "-b", "master"]);
1551 git(&repo, &["add", "."]);
1552 git(
1553 &repo,
1554 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1555 );
1556
1557 assert_eq!(auto_detect_base_branch(&repo), Some("master".to_string()));
1558 }
1559
1560 #[test]
1561 fn auto_detect_base_branch_returns_none_outside_git_repo() {
1562 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1563
1564 assert_eq!(auto_detect_base_branch(tmp.path()), None);
1565 }
1566
1567 #[test]
1568 fn get_head_sha_returns_short_head_for_git_repo() {
1569 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1570 let repo = init_throwaway_repo(tmp.path(), "repo");
1571 let output = Command::new("git")
1572 .args(["rev-parse", "--short", "HEAD"])
1573 .current_dir(&repo)
1574 .env_remove("GIT_DIR")
1575 .env_remove("GIT_WORK_TREE")
1576 .output()
1577 .expect("git rev-parse should run");
1578 assert!(output.status.success());
1579
1580 assert_eq!(
1581 get_head_sha(&repo),
1582 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1583 );
1584 }
1585
1586 #[test]
1587 fn get_head_sha_returns_none_outside_git_repo() {
1588 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1589
1590 assert_eq!(get_head_sha(tmp.path()), None);
1591 }
1592
1593 fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1594 list_audit_worktrees(repo_root)
1595 .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
1596 }
1597
1598 fn worktree_admin_entry_present(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1606 let basename = worktree_path
1607 .file_name()
1608 .and_then(|n| n.to_str())
1609 .expect("reusable worktree path has a utf-8 basename");
1610 let output = Command::new("git")
1611 .args(["worktree", "list", "--porcelain"])
1612 .current_dir(repo_root)
1613 .env_remove("GIT_DIR")
1614 .env_remove("GIT_WORK_TREE")
1615 .output()
1616 .expect("git worktree list should run");
1617 String::from_utf8_lossy(&output.stdout)
1618 .lines()
1619 .filter_map(|line| line.strip_prefix("worktree "))
1620 .any(|p| p.ends_with(basename))
1621 }
1622
1623 #[test]
1624 fn worktree_cleanup_guard_runs_on_drop() {
1625 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1626 let repo = init_throwaway_repo(tmp.path(), "repo");
1627 let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
1628
1629 git(
1630 &repo,
1631 &[
1632 "worktree",
1633 "add",
1634 "--detach",
1635 "--quiet",
1636 worktree_path.to_str().expect("path is utf-8"),
1637 "HEAD",
1638 ],
1639 );
1640 assert!(worktree_path.is_dir());
1641 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1642
1643 {
1644 let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1645 }
1646
1647 assert!(
1648 !worktree_path.exists(),
1649 "guard Drop should remove the worktree directory",
1650 );
1651 assert!(
1652 !worktree_is_registered_with_git(&repo, &worktree_path),
1653 "guard Drop should remove the git worktree registration",
1654 );
1655 }
1656
1657 #[test]
1658 fn worktree_cleanup_guard_defused_skips_drop() {
1659 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1660 let repo = init_throwaway_repo(tmp.path(), "repo");
1661 let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
1662
1663 git(
1664 &repo,
1665 &[
1666 "worktree",
1667 "add",
1668 "--detach",
1669 "--quiet",
1670 worktree_path.to_str().expect("path is utf-8"),
1671 "HEAD",
1672 ],
1673 );
1674 assert!(worktree_path.is_dir());
1675
1676 {
1677 let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1678 guard.defuse();
1679 guard.defuse();
1680 }
1681
1682 assert!(
1683 worktree_path.is_dir(),
1684 "defused guard must not remove the worktree on drop",
1685 );
1686 assert!(
1687 worktree_is_registered_with_git(&repo, &worktree_path),
1688 "defused guard must not unregister the worktree from git",
1689 );
1690
1691 remove_audit_worktree(&repo, &worktree_path);
1692 let _ = fs::remove_dir_all(&worktree_path);
1693 }
1694
1695 #[test]
1696 fn audit_orphan_sweep_removes_dead_pid_worktree() {
1697 const DEAD_PID: u32 = 99_999_999;
1698 assert!(!process_is_alive(DEAD_PID));
1699
1700 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1701 let repo = init_throwaway_repo(tmp.path(), "repo");
1702
1703 let worktree_path = std::env::temp_dir().join(format!(
1704 "fallow-audit-base-{}-{}",
1705 DEAD_PID,
1706 std::time::SystemTime::now()
1707 .duration_since(std::time::UNIX_EPOCH)
1708 .expect("clock should be after epoch")
1709 .as_nanos()
1710 ));
1711 git(
1712 &repo,
1713 &[
1714 "worktree",
1715 "add",
1716 "--detach",
1717 "--quiet",
1718 worktree_path.to_str().expect("path is utf-8"),
1719 "HEAD",
1720 ],
1721 );
1722 assert!(worktree_path.is_dir());
1723 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1724
1725 sweep_orphan_audit_worktrees(&repo);
1726
1727 assert!(
1728 !worktree_path.exists(),
1729 "sweep should remove worktree owned by a dead PID",
1730 );
1731 assert!(
1732 !worktree_is_registered_with_git(&repo, &worktree_path),
1733 "sweep should unregister worktree owned by a dead PID",
1734 );
1735 }
1736
1737 #[test]
1738 fn audit_orphan_sweep_keeps_live_pid_worktree() {
1739 let live_pid = std::process::id();
1740 assert!(process_is_alive(live_pid));
1741
1742 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1743 let repo = init_throwaway_repo(tmp.path(), "repo");
1744
1745 let worktree_path = std::env::temp_dir().join(format!(
1746 "fallow-audit-base-{}-{}",
1747 live_pid,
1748 std::time::SystemTime::now()
1749 .duration_since(std::time::UNIX_EPOCH)
1750 .expect("clock should be after epoch")
1751 .as_nanos()
1752 ));
1753 git(
1754 &repo,
1755 &[
1756 "worktree",
1757 "add",
1758 "--detach",
1759 "--quiet",
1760 worktree_path.to_str().expect("path is utf-8"),
1761 "HEAD",
1762 ],
1763 );
1764
1765 sweep_orphan_audit_worktrees(&repo);
1766
1767 assert!(
1768 worktree_path.is_dir(),
1769 "sweep must not remove worktree owned by a live PID",
1770 );
1771 assert!(
1772 worktree_is_registered_with_git(&repo, &worktree_path),
1773 "sweep must not unregister worktree owned by a live PID",
1774 );
1775
1776 remove_audit_worktree(&repo, &worktree_path);
1777 let _ = fs::remove_dir_all(&worktree_path);
1778 }
1779
1780 fn make_reusable_path(label: &str) -> PathBuf {
1784 let nanos = std::time::SystemTime::now()
1785 .duration_since(std::time::UNIX_EPOCH)
1786 .expect("clock should be after epoch")
1787 .as_nanos();
1788 std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
1789 }
1790
1791 fn register_reusable_worktree(repo: &Path, path: &Path) {
1795 git(
1796 repo,
1797 &[
1798 "worktree",
1799 "add",
1800 "--detach",
1801 "--quiet",
1802 path.to_str().expect("path is utf-8"),
1803 "HEAD",
1804 ],
1805 );
1806 }
1807
1808 fn write_sidecar_with_age(path: &Path, age: Duration) {
1809 let sidecar = reusable_worktree_last_used_path(path);
1810 let file = std::fs::OpenOptions::new()
1811 .create(true)
1812 .truncate(false)
1813 .write(true)
1814 .open(&sidecar)
1815 .expect("sidecar should open");
1816 let when = SystemTime::now()
1817 .checked_sub(age)
1818 .expect("backdated time should fit in SystemTime");
1819 file.set_modified(when)
1820 .expect("set_modified should succeed");
1821 }
1822
1823 fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
1826 remove_audit_worktree(repo, path);
1827 let _ = fs::remove_dir_all(path);
1828 let _ = fs::remove_file(reusable_worktree_last_used_path(path));
1829 let _ = fs::remove_file(reusable_worktree_lock_path(path));
1830 }
1831
1832 #[test]
1833 fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
1834 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1835 let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
1836 let worktree_path = make_reusable_path("gc-remove");
1837 register_reusable_worktree(&repo, &worktree_path);
1838 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
1839
1840 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1841
1842 assert!(
1843 !worktree_path.exists(),
1844 "sweep should remove worktree dir whose sidecar is older than the threshold",
1845 );
1846 assert!(
1847 !worktree_is_registered_with_git(&repo, &worktree_path),
1848 "sweep should unregister the worktree from git",
1849 );
1850 assert!(
1851 !reusable_worktree_last_used_path(&worktree_path).exists(),
1852 "sweep should remove the sidecar `.last-used` file alongside the worktree",
1853 );
1854 cleanup_reusable_worktree(&repo, &worktree_path);
1855 }
1856
1857 #[test]
1858 fn reusable_cache_gc_keeps_fresh_entry() {
1859 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1860 let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
1861 let worktree_path = make_reusable_path("gc-keep");
1862 register_reusable_worktree(&repo, &worktree_path);
1863 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1864
1865 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1866
1867 assert!(
1868 worktree_path.is_dir(),
1869 "sweep must not remove a worktree whose sidecar is fresher than the threshold",
1870 );
1871 assert!(
1872 worktree_is_registered_with_git(&repo, &worktree_path),
1873 "sweep must not unregister a fresh worktree",
1874 );
1875 cleanup_reusable_worktree(&repo, &worktree_path);
1876 }
1877
1878 #[test]
1879 fn reusable_cache_gc_skips_locked_entry() {
1880 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1881 let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
1882 let worktree_path = make_reusable_path("gc-locked");
1883 register_reusable_worktree(&repo, &worktree_path);
1884 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
1885
1886 let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
1887 .expect("test should acquire the lock first");
1888
1889 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1890
1891 assert!(
1892 worktree_path.is_dir(),
1893 "sweep must skip a locked entry even when its sidecar is stale",
1894 );
1895 assert!(
1896 worktree_is_registered_with_git(&repo, &worktree_path),
1897 "sweep must not unregister a locked entry",
1898 );
1899 drop(lock);
1900 cleanup_reusable_worktree(&repo, &worktree_path);
1901 }
1902
1903 #[test]
1904 fn reusable_cache_gc_grace_when_sidecar_absent() {
1905 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1906 let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
1907 let worktree_path = make_reusable_path("gc-grace");
1908 register_reusable_worktree(&repo, &worktree_path);
1909 let sidecar = reusable_worktree_last_used_path(&worktree_path);
1910 assert!(
1911 !sidecar.exists(),
1912 "test pre-condition: sidecar should not exist",
1913 );
1914
1915 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1916
1917 assert!(
1918 worktree_path.is_dir(),
1919 "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
1920 );
1921 assert!(
1922 sidecar.exists(),
1923 "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
1924 );
1925 let mtime = std::fs::metadata(&sidecar)
1926 .and_then(|m| m.modified())
1927 .expect("seeded sidecar should have a readable mtime");
1928 let age = SystemTime::now()
1929 .duration_since(mtime)
1930 .unwrap_or(Duration::ZERO);
1931 assert!(
1932 age < Duration::from_mins(1),
1933 "seeded sidecar mtime should be near `now()`, got age {age:?}",
1934 );
1935 cleanup_reusable_worktree(&repo, &worktree_path);
1936 }
1937
1938 #[test]
1939 fn reusable_cache_gc_reclaims_prunable_orphan_when_dir_missing() {
1940 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1941 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
1942 let worktree_path = make_reusable_path("gc-orphan");
1943 register_reusable_worktree(&repo, &worktree_path);
1944 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1947 let sidecar = reusable_worktree_last_used_path(&worktree_path);
1948
1949 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
1952 assert!(
1953 !worktree_path.exists(),
1954 "test pre-condition: cache dir should be gone",
1955 );
1956 assert!(
1957 worktree_admin_entry_present(&repo, &worktree_path),
1958 "test pre-condition: git admin entry should still be registered (prunable)",
1959 );
1960 assert!(
1961 sidecar.exists(),
1962 "test pre-condition: sidecar survives a dir-only reaper",
1963 );
1964
1965 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1966
1967 assert!(
1968 !worktree_admin_entry_present(&repo, &worktree_path),
1969 "sweep should unregister a prunable orphan whose dir was externally removed",
1970 );
1971 assert!(
1972 !sidecar.exists(),
1973 "sweep should remove the stale sidecar for a reclaimed orphan",
1974 );
1975 cleanup_reusable_worktree(&repo, &worktree_path);
1976 }
1977
1978 #[test]
1979 fn reusable_cache_gc_reclaims_prunable_orphan_even_when_age_gc_disabled() {
1980 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1981 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
1982 let worktree_path = make_reusable_path("gc-orphan-nogc");
1983 register_reusable_worktree(&repo, &worktree_path);
1984 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1985 let sidecar = reusable_worktree_last_used_path(&worktree_path);
1986 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
1987 assert!(
1988 worktree_admin_entry_present(&repo, &worktree_path),
1989 "test pre-condition: git admin entry should still be registered (prunable)",
1990 );
1991 assert!(
1992 sidecar.exists(),
1993 "test pre-condition: sidecar survives a dir-only reaper",
1994 );
1995
1996 sweep_old_reusable_caches(&repo, None, true);
1999
2000 assert!(
2001 !worktree_admin_entry_present(&repo, &worktree_path),
2002 "orphan reclaim must run even when age-based GC is disabled",
2003 );
2004 assert!(
2005 !sidecar.exists(),
2006 "sweep should remove the stale sidecar even when age-based GC is disabled",
2007 );
2008 cleanup_reusable_worktree(&repo, &worktree_path);
2009 }
2010
2011 #[test]
2012 fn reusable_cache_gc_preserves_lock_file_after_removal() {
2013 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2014 let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
2015 let worktree_path = make_reusable_path("gc-lockfile");
2016 register_reusable_worktree(&repo, &worktree_path);
2017 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2018 let lock_path = reusable_worktree_lock_path(&worktree_path);
2019 drop(
2020 ReusableWorktreeLock::try_acquire(&worktree_path)
2021 .expect("test should acquire the lock"),
2022 );
2023 assert!(
2024 lock_path.exists(),
2025 "test pre-condition: lock file should exist before sweep",
2026 );
2027
2028 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2029
2030 assert!(
2031 !worktree_path.exists(),
2032 "sweep should still remove the worktree directory",
2033 );
2034 assert!(
2035 lock_path.exists(),
2036 "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
2037 );
2038 let _ = fs::remove_file(&lock_path);
2039 cleanup_reusable_worktree(&repo, &worktree_path);
2040 }
2041
2042 #[test]
2043 fn reuse_or_create_stamps_sidecar_on_fresh_create() {
2044 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2045 let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
2046 let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2047
2048 let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
2049 .expect("fresh reuse_or_create should succeed on a clean repo");
2050 let cache_path = worktree.path().to_path_buf();
2051 let sidecar = reusable_worktree_last_used_path(&cache_path);
2052
2053 assert!(
2054 sidecar.exists(),
2055 "fresh-create must write the sidecar so age is measured from now",
2056 );
2057 let initial_age = std::fs::metadata(&sidecar)
2058 .and_then(|m| m.modified())
2059 .ok()
2060 .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
2061 .expect("sidecar mtime should be readable and not in the future");
2062 assert!(
2063 initial_age < Duration::from_mins(1),
2064 "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
2065 );
2066
2067 drop(worktree);
2068 cleanup_reusable_worktree(&repo, &cache_path);
2069 }
2070
2071 #[test]
2072 fn days_to_duration_zero_disables() {
2073 assert!(days_to_duration(0).is_none());
2074 assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
2075 assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
2076 }
2077
2078 #[test]
2079 fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
2080 let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
2081 let sidecar = reusable_worktree_last_used_path(&cache_dir);
2082 assert_eq!(sidecar.parent(), cache_dir.parent());
2083 assert_eq!(
2084 sidecar.file_name().and_then(|s| s.to_str()),
2085 Some("fallow-audit-base-cache-abcd-1234.last-used"),
2086 );
2087 }
2088
2089 #[test]
2090 fn touch_last_used_creates_sidecar_if_missing() {
2091 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2092 let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
2093 fs::create_dir(&cache_dir).expect("cache dir should be created");
2094 let sidecar = reusable_worktree_last_used_path(&cache_dir);
2095 assert!(!sidecar.exists(), "sidecar should not exist before touch");
2096
2097 touch_last_used(&cache_dir);
2098
2099 assert!(sidecar.exists(), "touch should create the sidecar");
2100 let mtime = fs::metadata(&sidecar)
2101 .and_then(|m| m.modified())
2102 .expect("sidecar should have an mtime");
2103 let age = SystemTime::now()
2104 .duration_since(mtime)
2105 .unwrap_or(Duration::ZERO);
2106 assert!(
2107 age < Duration::from_mins(1),
2108 "touched sidecar should be near `now()`",
2109 );
2110 }
2111
2112 #[test]
2113 fn reusable_worktree_lock_excludes_concurrent_acquires() {
2114 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2115 let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
2116 let lock_path = reusable_worktree_lock_path(&reusable);
2117
2118 let first = ReusableWorktreeLock::try_acquire(&reusable)
2119 .expect("first acquire on a fresh path should succeed");
2120 assert!(
2121 ReusableWorktreeLock::try_acquire(&reusable).is_none(),
2122 "second acquire must fail while the first is held",
2123 );
2124 drop(first);
2125 assert!(
2126 lock_path.exists(),
2127 "lock file must persist after drop (only the kernel lock is released)",
2128 );
2129 }
2130
2131 #[test]
2132 fn base_analysis_root_preserves_repo_subdirectory_roots() {
2133 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2134 let repo = tmp.path().join("repo");
2135 let app_root = repo.join("apps/mobile");
2136 let base_worktree = tmp.path().join("base-worktree");
2137 fs::create_dir_all(&app_root).expect("app root should be created");
2138 fs::create_dir_all(&base_worktree).expect("base worktree should be created");
2139 git(&repo, &["init", "-b", "main"]);
2140
2141 assert_eq!(
2142 base_analysis_root(&app_root, &base_worktree),
2143 base_worktree.join("apps/mobile")
2144 );
2145 }
2146
2147 #[test]
2148 fn audit_base_worktree_reuses_current_node_modules_context() {
2149 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2150 let root = tmp.path();
2151 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2152 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2153 .expect("gitignore should be written");
2154 fs::write(
2155 root.join("package.json"),
2156 r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
2157 )
2158 .expect("package.json should be written");
2159 fs::write(
2160 root.join("tsconfig.json"),
2161 r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
2162 )
2163 .expect("tsconfig should be written");
2164 fs::write(
2165 root.join("src/index.ts"),
2166 "import { used } from '@/feature';\nconsole.log(used);\n",
2167 )
2168 .expect("index should be written");
2169 fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
2170 .expect("feature should be written");
2171
2172 git(root, &["init", "-b", "main"]);
2173 git(root, &["add", "."]);
2174 git(
2175 root,
2176 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2177 );
2178
2179 let rn_config = root.join("node_modules/@react-native/typescript-config");
2180 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2181 fs::write(
2182 rn_config.join("tsconfig.json"),
2183 r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
2184 )
2185 .expect("node_modules tsconfig should be written");
2186
2187 let worktree =
2188 BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
2189 assert!(
2190 worktree.path().join("node_modules").is_dir(),
2191 "base worktree should reuse ignored node_modules from the current checkout"
2192 );
2193 assert!(
2194 worktree
2195 .path()
2196 .join("node_modules/@react-native/typescript-config/tsconfig.json")
2197 .is_file(),
2198 "base worktree should preserve tsconfig extends targets installed in node_modules"
2199 );
2200 }
2201
2202 #[test]
2212 fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
2213 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2214 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2215
2216 let dot_nuxt = host.path().join(".nuxt");
2217 fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
2218 fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
2219 .expect(".nuxt/tsconfig.json should be written");
2220 fs::write(
2221 dot_nuxt.join("tsconfig.app.json"),
2222 r#"{"compilerOptions":{}}"#,
2223 )
2224 .expect(".nuxt/tsconfig.app.json should be written");
2225
2226 materialize_base_dependency_context(host.path(), worktree.path());
2227
2228 let mirrored = worktree.path().join(".nuxt");
2229 assert!(
2230 mirrored.is_dir(),
2231 "base worktree should reuse the ignored .nuxt dir from the host checkout"
2232 );
2233 let link_meta = fs::symlink_metadata(&mirrored)
2234 .expect(".nuxt entry should exist as a symlink in the worktree");
2235 assert!(
2236 link_meta.file_type().is_symlink(),
2237 "base worktree's .nuxt should be a symlink to the host checkout"
2238 );
2239 assert!(
2240 mirrored.join("tsconfig.json").is_file(),
2241 "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
2242 prerequisite check stays quiet"
2243 );
2244 assert!(
2245 mirrored.join("tsconfig.app.json").is_file(),
2246 "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
2247 resolve without falling back to resolver-less resolution"
2248 );
2249 }
2250
2251 #[test]
2256 fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
2257 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2258 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2259
2260 let dot_astro = host.path().join(".astro");
2261 fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
2262 fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
2263 .expect(".astro/types.d.ts should be written");
2264
2265 materialize_base_dependency_context(host.path(), worktree.path());
2266
2267 let mirrored = worktree.path().join(".astro");
2268 assert!(
2269 mirrored.is_dir(),
2270 "base worktree should reuse the ignored .astro dir from the host checkout"
2271 );
2272 assert!(
2273 mirrored.join("types.d.ts").is_file(),
2274 "base worktree should expose generated Astro types so the Astro meta-framework \
2275 prerequisite check stays quiet"
2276 );
2277 }
2278
2279 #[test]
2286 fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
2287 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2288 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2289
2290 materialize_base_dependency_context(host.path(), worktree.path());
2291
2292 assert!(
2293 !worktree.path().join(".nuxt").exists(),
2294 "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
2295 );
2296 assert!(
2297 !worktree.path().join(".astro").exists(),
2298 "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
2299 );
2300 assert!(
2301 !worktree.path().join("node_modules").exists(),
2302 "base worktree should not fabricate a node_modules symlink when the host has none"
2303 );
2304 }
2305
2306 #[test]
2310 fn materialize_base_dependency_context_handles_each_dir_independently() {
2311 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2312 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2313
2314 fs::create_dir_all(host.path().join("node_modules"))
2315 .expect("host node_modules should be created");
2316
2317 materialize_base_dependency_context(host.path(), worktree.path());
2318
2319 assert!(
2320 worktree.path().join("node_modules").is_dir(),
2321 "node_modules should still be symlinked even when host has no .nuxt or .astro"
2322 );
2323 assert!(
2324 !worktree.path().join(".nuxt").exists(),
2325 "missing host .nuxt should leave the worktree slot empty"
2326 );
2327 }
2328
2329 #[test]
2336 fn materialize_base_dependency_context_preserves_real_worktree_dir() {
2337 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2338 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2339
2340 let host_nuxt = host.path().join(".nuxt");
2341 fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
2342 fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
2343 .expect("host .nuxt/tsconfig.json should be written");
2344
2345 let worktree_nuxt = worktree.path().join(".nuxt");
2346 fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
2347 fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
2348 .expect("worktree .nuxt/tsconfig.json should be written");
2349
2350 materialize_base_dependency_context(host.path(), worktree.path());
2351
2352 let link_meta = fs::symlink_metadata(&worktree_nuxt)
2353 .expect(".nuxt entry should still exist in the worktree");
2354 assert!(
2355 !link_meta.file_type().is_symlink(),
2356 "a real base-tracked .nuxt dir must not be replaced by a host symlink"
2357 );
2358 let contents =
2359 fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
2360 assert!(
2361 contents.contains("base"),
2362 "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
2363 );
2364 }
2365
2366 #[test]
2367 fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
2368 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2369 let root = tmp.path();
2370 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2371 .expect("gitignore should be written");
2372 fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
2373 .expect("package.json should be written");
2374
2375 git(root, &["init", "-b", "main"]);
2376 git(root, &["add", "."]);
2377 git(
2378 root,
2379 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2380 );
2381
2382 let rn_config = root.join("node_modules/@react-native/typescript-config");
2383 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2384 fs::write(rn_config.join("tsconfig.json"), "{}")
2385 .expect("node_modules tsconfig should be written");
2386
2387 let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
2388 let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2389 .expect("persistent base worktree should be created");
2390 let worktree_path = first.path().to_path_buf();
2391 assert!(
2392 worktree_path.join("node_modules").is_dir(),
2393 "initial persistent worktree should receive node_modules context"
2394 );
2395 remove_node_modules_context(&worktree_path);
2396 assert!(
2397 !worktree_path.join("node_modules").exists(),
2398 "test setup should remove the dependency context from the reusable worktree"
2399 );
2400 drop(first);
2401
2402 let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2403 .expect("ready persistent base worktree should be reused");
2404 assert_eq!(reused.path(), worktree_path.as_path());
2405 assert!(
2406 reused.path().join("node_modules").is_dir(),
2407 "ready persistent worktree should refresh missing node_modules context"
2408 );
2409
2410 remove_audit_worktree(root, reused.path());
2411 let _ = fs::remove_dir_all(reused.path());
2412 }
2413
2414 fn remove_node_modules_context(worktree_path: &Path) {
2415 let path = worktree_path.join("node_modules");
2416 let Ok(metadata) = fs::symlink_metadata(&path) else {
2417 return;
2418 };
2419 if metadata.file_type().is_symlink() {
2420 #[cfg(unix)]
2421 let _ = fs::remove_file(path);
2422 #[cfg(windows)]
2423 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
2424 } else {
2425 let _ = fs::remove_dir_all(path);
2426 }
2427 }
2428
2429 #[test]
2430 fn audit_base_snapshot_cache_payload_roundtrips_sets() {
2431 let key = AuditBaseSnapshotCacheKey {
2432 hash: 42,
2433 base_sha: "abc123".to_string(),
2434 };
2435 let snapshot = AuditKeySnapshot {
2436 dead_code: ["dead:a".to_string(), "dead:b".to_string()]
2437 .into_iter()
2438 .collect(),
2439 health: std::iter::once("health:a".to_string()).collect(),
2440 dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
2441 .into_iter()
2442 .collect(),
2443 };
2444
2445 let cached = cached_from_snapshot(&key, &snapshot);
2446 assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
2447 assert_eq!(cached.key_hash, key.hash);
2448 assert_eq!(cached.base_sha, key.base_sha);
2449 assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
2450
2451 let decoded = snapshot_from_cached(cached);
2452 assert_eq!(decoded.dead_code, snapshot.dead_code);
2453 assert_eq!(decoded.health, snapshot.health);
2454 assert_eq!(decoded.dupes, snapshot.dupes);
2455 }
2456
2457 #[test]
2458 fn audit_base_snapshot_cache_dir_writes_gitignore() {
2459 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2460 let cache_root = tmp.path().join(".custom-fallow-cache");
2461 let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2462
2463 ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2464
2465 assert_eq!(
2466 fs::read_to_string(cache_dir.join(".gitignore")).expect("gitignore should read"),
2467 "*\n"
2468 );
2469 }
2470
2471 #[test]
2472 fn audit_base_snapshot_cache_roundtrips_from_disk() {
2473 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2474 let config_path = None;
2475 let cache_root = tmp.path().join(".custom-fallow-cache");
2476 let opts = AuditOptions {
2477 root: tmp.path(),
2478 cache_dir: &cache_root,
2479 config_path: &config_path,
2480 output: OutputFormat::Json,
2481 no_cache: false,
2482 threads: 1,
2483 quiet: true,
2484 changed_since: Some("HEAD"),
2485 production: false,
2486 production_dead_code: None,
2487 production_health: None,
2488 production_dupes: None,
2489 workspace: None,
2490 changed_workspaces: None,
2491 explain: false,
2492 explain_skipped: false,
2493 performance: false,
2494 group_by: None,
2495 dead_code_baseline: None,
2496 health_baseline: None,
2497 dupes_baseline: None,
2498 max_crap: None,
2499 coverage: None,
2500 coverage_root: None,
2501 gate: AuditGate::NewOnly,
2502 include_entry_exports: false,
2503 runtime_coverage: None,
2504 min_invocations_hot: 100,
2505 };
2506 let key = AuditBaseSnapshotCacheKey {
2507 hash: 0xfeed,
2508 base_sha: "abc123".to_string(),
2509 };
2510 let snapshot = AuditKeySnapshot {
2511 dead_code: std::iter::once("dead:a".to_string()).collect(),
2512 health: std::iter::once("health:a".to_string()).collect(),
2513 dupes: std::iter::once("dupe:a".to_string()).collect(),
2514 };
2515
2516 save_cached_base_snapshot(&opts, &key, &snapshot);
2517 assert!(
2518 audit_base_snapshot_cache_file(&cache_root, &key).exists(),
2519 "snapshot should be saved below the configured cache directory"
2520 );
2521 let loaded = load_cached_base_snapshot(&opts, &key).expect("snapshot should load");
2522
2523 assert_eq!(loaded.dead_code, snapshot.dead_code);
2524 assert_eq!(loaded.health, snapshot.health);
2525 assert_eq!(loaded.dupes, snapshot.dupes);
2526 }
2527
2528 #[test]
2529 fn audit_base_snapshot_cache_rejects_mismatched_key() {
2530 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2531 let config_path = None;
2532 let cache_root = tmp.path().join(".custom-fallow-cache");
2533 let opts = AuditOptions {
2534 root: tmp.path(),
2535 cache_dir: &cache_root,
2536 config_path: &config_path,
2537 output: OutputFormat::Json,
2538 no_cache: false,
2539 threads: 1,
2540 quiet: true,
2541 changed_since: Some("HEAD"),
2542 production: false,
2543 production_dead_code: None,
2544 production_health: None,
2545 production_dupes: None,
2546 workspace: None,
2547 changed_workspaces: None,
2548 explain: false,
2549 explain_skipped: false,
2550 performance: false,
2551 group_by: None,
2552 dead_code_baseline: None,
2553 health_baseline: None,
2554 dupes_baseline: None,
2555 max_crap: None,
2556 coverage: None,
2557 coverage_root: None,
2558 gate: AuditGate::NewOnly,
2559 include_entry_exports: false,
2560 runtime_coverage: None,
2561 min_invocations_hot: 100,
2562 };
2563 let key = AuditBaseSnapshotCacheKey {
2564 hash: 0xbeef,
2565 base_sha: "head".to_string(),
2566 };
2567 let cached = CachedAuditKeySnapshot {
2568 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
2569 cli_version: env!("CARGO_PKG_VERSION").to_string(),
2570 key_hash: key.hash,
2571 base_sha: "other".to_string(),
2572 dead_code: vec!["dead:a".to_string()],
2573 health: vec![],
2574 dupes: vec![],
2575 };
2576 let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2577 ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2578 fs::write(
2579 audit_base_snapshot_cache_file(&cache_root, &key),
2580 bitcode::encode(&cached),
2581 )
2582 .expect("cache file should be written");
2583
2584 assert!(load_cached_base_snapshot(&opts, &key).is_none());
2585 }
2586
2587 #[test]
2588 fn audit_base_snapshot_cache_key_includes_extended_config() {
2589 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2590 let root = tmp.path();
2591 fs::write(
2592 root.join(".fallowrc.json"),
2593 r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
2594 )
2595 .expect("config should be written");
2596 fs::write(
2597 root.join("base.json"),
2598 r#"{"rules":{"unused-exports":"off"}}"#,
2599 )
2600 .expect("base config should be written");
2601
2602 let config_path = None;
2603 let cache_root = root.join(".fallow");
2604 let opts = AuditOptions {
2605 root,
2606 cache_dir: &cache_root,
2607 config_path: &config_path,
2608 output: OutputFormat::Json,
2609 no_cache: false,
2610 threads: 1,
2611 quiet: true,
2612 changed_since: Some("HEAD"),
2613 production: false,
2614 production_dead_code: None,
2615 production_health: None,
2616 production_dupes: None,
2617 workspace: None,
2618 changed_workspaces: None,
2619 explain: false,
2620 explain_skipped: false,
2621 performance: false,
2622 group_by: None,
2623 dead_code_baseline: None,
2624 health_baseline: None,
2625 dupes_baseline: None,
2626 max_crap: None,
2627 coverage: None,
2628 coverage_root: None,
2629 gate: AuditGate::NewOnly,
2630 include_entry_exports: false,
2631 runtime_coverage: None,
2632 min_invocations_hot: 100,
2633 };
2634
2635 let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
2636 fs::write(
2637 root.join("base.json"),
2638 r#"{"rules":{"unused-exports":"error"}}"#,
2639 )
2640 .expect("base config should be updated");
2641 let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
2642
2643 assert_ne!(
2644 first["resolved_hash"], second["resolved_hash"],
2645 "extended config changes must invalidate cached base snapshots"
2646 );
2647 }
2648
2649 #[test]
2650 fn audit_gate_all_skips_base_snapshot() {
2651 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2652 let root = tmp.path();
2653 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2654 fs::write(
2655 root.join("package.json"),
2656 r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
2657 )
2658 .expect("package.json should be written");
2659 fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
2660 .expect("index should be written");
2661
2662 git(root, &["init", "-b", "main"]);
2663 git(root, &["add", "."]);
2664 git(
2665 root,
2666 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2667 );
2668 fs::write(
2669 root.join("src/index.ts"),
2670 "export const legacy = 1;\nexport const changed = 2;\n",
2671 )
2672 .expect("changed module should be written");
2673
2674 let config_path = None;
2675 let cache_root = root.join(".fallow");
2676 let opts = AuditOptions {
2677 root,
2678 cache_dir: &cache_root,
2679 config_path: &config_path,
2680 output: OutputFormat::Json,
2681 no_cache: true,
2682 threads: 1,
2683 quiet: true,
2684 changed_since: Some("HEAD"),
2685 production: false,
2686 production_dead_code: None,
2687 production_health: None,
2688 production_dupes: None,
2689 workspace: None,
2690 changed_workspaces: None,
2691 explain: false,
2692 explain_skipped: false,
2693 performance: false,
2694 group_by: None,
2695 dead_code_baseline: None,
2696 health_baseline: None,
2697 dupes_baseline: None,
2698 max_crap: None,
2699 coverage: None,
2700 coverage_root: None,
2701 gate: AuditGate::All,
2702 include_entry_exports: false,
2703 runtime_coverage: None,
2704 min_invocations_hot: 100,
2705 };
2706
2707 let result = execute_audit(&opts).expect("audit should execute");
2708 assert!(result.base_snapshot.is_none());
2709 assert_eq!(result.attribution.gate, AuditGate::All);
2710 assert_eq!(result.attribution.dead_code_introduced, 0);
2711 assert_eq!(result.attribution.dead_code_inherited, 0);
2712 }
2713
2714 #[test]
2715 fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
2716 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2717 let root = tmp.path();
2718 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2719 fs::write(
2720 root.join("package.json"),
2721 r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
2722 )
2723 .expect("package.json should be written");
2724 fs::write(
2725 root.join(".fallowrc.json"),
2726 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
2727 )
2728 .expect("config should be written");
2729 let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
2730 fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
2731 fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
2732 fs::write(root.join("README.md"), "before\n").expect("readme should be written");
2733
2734 git(root, &["init", "-b", "main"]);
2735 git(root, &["add", "."]);
2736 git(
2737 root,
2738 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2739 );
2740 fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
2741 fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
2742 .expect("cache dir should be created");
2743 fs::write(
2744 root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
2745 b"cache",
2746 )
2747 .expect("cache artifact should be written");
2748
2749 let before_worktrees = audit_worktree_names(root);
2750
2751 let config_path = None;
2752 let cache_root = root.join(".fallow");
2753 let opts = AuditOptions {
2754 root,
2755 cache_dir: &cache_root,
2756 config_path: &config_path,
2757 output: OutputFormat::Json,
2758 no_cache: true,
2759 threads: 1,
2760 quiet: true,
2761 changed_since: Some("HEAD"),
2762 production: false,
2763 production_dead_code: None,
2764 production_health: None,
2765 production_dupes: None,
2766 workspace: None,
2767 changed_workspaces: None,
2768 explain: false,
2769 explain_skipped: false,
2770 performance: true,
2771 group_by: None,
2772 dead_code_baseline: None,
2773 health_baseline: None,
2774 dupes_baseline: None,
2775 max_crap: None,
2776 coverage: None,
2777 coverage_root: None,
2778 gate: AuditGate::NewOnly,
2779 include_entry_exports: false,
2780 runtime_coverage: None,
2781 min_invocations_hot: 100,
2782 };
2783
2784 let result = execute_audit(&opts).expect("audit should execute");
2785 assert_eq!(result.verdict, AuditVerdict::Pass);
2786 assert_eq!(result.changed_files_count, 2);
2787 assert!(result.base_snapshot_skipped);
2788 assert!(result.base_snapshot.is_some());
2789
2790 let after_worktrees = audit_worktree_names(root);
2791 assert_eq!(
2792 before_worktrees, after_worktrees,
2793 "base snapshot skip must not create a temporary base worktree"
2794 );
2795 }
2796
2797 fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
2798 let mut names: Vec<String> = list_audit_worktrees(repo_root)
2799 .unwrap_or_default()
2800 .into_iter()
2801 .filter_map(|path| {
2802 path.file_name()
2803 .and_then(|name| name.to_str())
2804 .map(str::to_owned)
2805 })
2806 .collect();
2807 names.sort();
2808 names
2809 }
2810
2811 #[test]
2812 fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
2813 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2814 let root = tmp.path();
2815 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2816 fs::write(
2817 root.join("package.json"),
2818 r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
2819 )
2820 .expect("package.json should be written");
2821 fs::write(
2822 root.join("src/index.ts"),
2823 "import { used } from './used';\nused();\n",
2824 )
2825 .expect("index should be written");
2826 fs::write(
2827 root.join("src/used.ts"),
2828 "export function used() {\n return 1;\n}\n",
2829 )
2830 .expect("used module should be written");
2831
2832 git(root, &["init", "-b", "main"]);
2833 git(root, &["add", "."]);
2834 git(
2835 root,
2836 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2837 );
2838 fs::write(
2839 root.join("src/used.ts"),
2840 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
2841 )
2842 .expect("changed module should be written");
2843
2844 let config_path = None;
2845 let cache_root = root.join(".fallow");
2846 let opts = AuditOptions {
2847 root,
2848 cache_dir: &cache_root,
2849 config_path: &config_path,
2850 output: OutputFormat::Json,
2851 no_cache: true,
2852 threads: 1,
2853 quiet: true,
2854 changed_since: Some("HEAD"),
2855 production: false,
2856 production_dead_code: None,
2857 production_health: None,
2858 production_dupes: None,
2859 workspace: None,
2860 changed_workspaces: None,
2861 explain: false,
2862 explain_skipped: false,
2863 performance: true,
2864 group_by: None,
2865 dead_code_baseline: None,
2866 health_baseline: None,
2867 dupes_baseline: None,
2868 max_crap: None,
2869 coverage: None,
2870 coverage_root: None,
2871 gate: AuditGate::NewOnly,
2872 include_entry_exports: false,
2873 runtime_coverage: None,
2874 min_invocations_hot: 100,
2875 };
2876
2877 let result = execute_audit(&opts).expect("audit should execute");
2878 let health = result.health.expect("health should run for changed files");
2879 let timings = health.timings.expect("performance timings should be kept");
2880 assert!(timings.discover_ms.abs() < f64::EPSILON);
2881 assert!(timings.parse_ms.abs() < f64::EPSILON);
2882 assert!(
2883 result.dupes.is_some(),
2884 "dupes should run when changed files exist"
2885 );
2886 }
2887
2888 #[test]
2889 fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
2890 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2891 let root = tmp.path();
2892 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2893 fs::write(
2894 root.join("package.json"),
2895 r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
2896 )
2897 .expect("package.json should be written");
2898 fs::write(
2899 root.join("src/index.ts"),
2900 "import { used } from './used';\nused();\n",
2901 )
2902 .expect("index should be written");
2903 fs::write(
2904 root.join("src/used.ts"),
2905 "export function used() {\n return 1;\n}\n",
2906 )
2907 .expect("used module should be written");
2908
2909 git(root, &["init", "-b", "main"]);
2910 git(root, &["add", "."]);
2911 git(
2912 root,
2913 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2914 );
2915 fs::write(
2916 root.join("src/used.ts"),
2917 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
2918 )
2919 .expect("changed module should be written");
2920
2921 let config_path = None;
2922 let cache_root = root.join(".fallow");
2923 let opts = AuditOptions {
2924 root,
2925 cache_dir: &cache_root,
2926 config_path: &config_path,
2927 output: OutputFormat::Json,
2928 no_cache: true,
2929 threads: 1,
2930 quiet: true,
2931 changed_since: Some("HEAD"),
2932 production: false,
2933 production_dead_code: Some(true),
2934 production_health: Some(false),
2935 production_dupes: Some(false),
2936 workspace: None,
2937 changed_workspaces: None,
2938 explain: false,
2939 explain_skipped: false,
2940 performance: true,
2941 group_by: None,
2942 dead_code_baseline: None,
2943 health_baseline: None,
2944 dupes_baseline: None,
2945 max_crap: None,
2946 coverage: None,
2947 coverage_root: None,
2948 gate: AuditGate::NewOnly,
2949 include_entry_exports: false,
2950 runtime_coverage: None,
2951 min_invocations_hot: 100,
2952 };
2953
2954 let result = execute_audit(&opts).expect("audit should execute");
2955 assert!(result.dupes.is_some(), "dupes should still run");
2956 }
2957
2958 #[cfg(unix)]
2959 #[test]
2960 fn remap_focus_files_does_not_canonicalize_through_symlinks() {
2961 let tmp = tempfile::TempDir::new().expect("temp dir");
2962 let real = tmp.path().join("real");
2963 let link = tmp.path().join("link");
2964 fs::create_dir_all(&real).expect("real dir");
2965 std::os::unix::fs::symlink(&real, &link).expect("symlink");
2966 let canonical = link.canonicalize().expect("canonicalize symlink");
2967 assert_ne!(link, canonical, "symlink should not equal its target");
2968
2969 let from_root = PathBuf::from("/repo");
2970 let mut focus = FxHashSet::default();
2971 focus.insert(from_root.join("src/foo.ts"));
2972
2973 let remapped = remap_focus_files(&focus, &from_root, &link)
2974 .expect("remap should succeed for in-prefix files");
2975
2976 let expected = link.join("src/foo.ts");
2977 assert!(
2978 remapped.contains(&expected),
2979 "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
2980 );
2981 }
2982
2983 #[test]
2984 fn remap_focus_files_skips_paths_outside_from_root() {
2985 let from_root = PathBuf::from("/repo/apps/web");
2986 let to_root = PathBuf::from("/wt/apps/web");
2987 let mut focus = FxHashSet::default();
2988 focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
2989 focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
2990
2991 let remapped =
2992 remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
2993
2994 assert_eq!(remapped.len(), 1);
2995 assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
2996 }
2997
2998 #[test]
2999 fn remap_focus_files_returns_none_when_no_paths_map() {
3000 let from_root = PathBuf::from("/repo/apps/web");
3001 let to_root = PathBuf::from("/wt/apps/web");
3002 let mut focus = FxHashSet::default();
3003 focus.insert(PathBuf::from("/elsewhere/foo.ts"));
3004
3005 let remapped = remap_focus_files(&focus, &from_root, &to_root);
3006 assert!(
3007 remapped.is_none(),
3008 "remap should return None when no paths can be mapped, falling caller back to full corpus"
3009 );
3010 }
3011
3012 #[test]
3013 fn remap_cache_dir_moves_project_local_cache_to_base_worktree() {
3014 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3015 let current_root = tmp.path().join("repo");
3016 let base_root = tmp.path().join("fallow-base");
3017 let cache_dir = current_root.join(".cache").join("fallow");
3018
3019 let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
3020
3021 assert_eq!(remapped, base_root.join(".cache").join("fallow"));
3022 }
3023
3024 #[test]
3025 fn remap_cache_dir_keeps_external_absolute_cache_shared() {
3026 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3027 let current_root = tmp.path().join("repo");
3028 let base_root = tmp.path().join("fallow-base");
3029 let cache_dir = tmp.path().join("shared").join("fallow-cache");
3030
3031 let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
3032
3033 assert_eq!(remapped, cache_dir);
3034 }
3035
3036 #[test]
3037 fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
3038 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3039 let root_buf = tmp
3040 .path()
3041 .canonicalize()
3042 .expect("temp root should canonicalize");
3043 let root = root_buf.as_path();
3044 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3045 fs::write(
3046 root.join("package.json"),
3047 r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
3048 )
3049 .expect("package.json should be written");
3050 fs::write(
3051 root.join(".fallowrc.json"),
3052 r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
3053 )
3054 .expect("config should be written");
3055
3056 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";
3057 fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
3058 fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
3059
3060 git(root, &["init", "-b", "main"]);
3061 git(root, &["add", "."]);
3062 git(
3063 root,
3064 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3065 );
3066 fs::write(
3067 root.join("src/changed.ts"),
3068 format!("{dup_block}// touched\n"),
3069 )
3070 .expect("changed file should be modified");
3071 git(root, &["add", "."]);
3072 git(
3073 root,
3074 &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
3075 );
3076
3077 let config_path = None;
3078 let cache_root = root.join(".fallow");
3079 let opts = AuditOptions {
3080 root,
3081 cache_dir: &cache_root,
3082 config_path: &config_path,
3083 output: OutputFormat::Json,
3084 no_cache: true,
3085 threads: 1,
3086 quiet: true,
3087 changed_since: Some("HEAD~1"),
3088 production: false,
3089 production_dead_code: None,
3090 production_health: None,
3091 production_dupes: None,
3092 workspace: None,
3093 changed_workspaces: None,
3094 explain: false,
3095 explain_skipped: false,
3096 performance: false,
3097 group_by: None,
3098 dead_code_baseline: None,
3099 health_baseline: None,
3100 dupes_baseline: None,
3101 max_crap: None,
3102 coverage: None,
3103 coverage_root: None,
3104 gate: AuditGate::NewOnly,
3105 include_entry_exports: false,
3106 runtime_coverage: None,
3107 min_invocations_hot: 100,
3108 };
3109
3110 let result = execute_audit(&opts).expect("audit should execute");
3111 assert!(
3112 result.base_snapshot_skipped,
3113 "comment-only JS/TS diffs should reuse current keys as the base snapshot"
3114 );
3115 let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
3116 assert!(
3117 !dupes_report.clone_groups.is_empty(),
3118 "current run should detect the pre-existing duplicate"
3119 );
3120 assert_eq!(
3121 result.attribution.duplication_introduced, 0,
3122 "pre-existing duplicate must not be classified as introduced; \
3123 attribution = {:?}",
3124 result.attribution
3125 );
3126 assert!(
3127 result.attribution.duplication_inherited > 0,
3128 "pre-existing duplicate must be classified as inherited; \
3129 attribution = {:?}",
3130 result.attribution
3131 );
3132 }
3133
3134 #[test]
3135 fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
3136 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3137 let root = tmp.path();
3138 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3139 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3140 .expect("node_modules config dir should be created");
3141 fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
3142 fs::write(
3143 root.join("package.json"),
3144 r#"{
3145 "name": "audit-react-native-tsconfig-base",
3146 "private": true,
3147 "main": "src/App.tsx",
3148 "dependencies": {
3149 "react-native": "0.80.0"
3150 }
3151 }"#,
3152 )
3153 .expect("package.json should be written");
3154 fs::write(
3155 root.join("tsconfig.json"),
3156 r#"{
3157 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3158 "compilerOptions": {
3159 "baseUrl": ".",
3160 "paths": {
3161 "@/*": ["src/*"]
3162 }
3163 },
3164 "include": ["src/**/*"]
3165 }"#,
3166 )
3167 .expect("tsconfig should be written");
3168 fs::write(
3169 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3170 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3171 )
3172 .expect("react native tsconfig should be written");
3173 fs::write(
3174 root.join("src/App.tsx"),
3175 r#"import { homeTitle } from "@/screens/Home";
3176
3177export function App() {
3178 return homeTitle;
3179}
3180"#,
3181 )
3182 .expect("app should be written");
3183 fs::write(
3184 root.join("src/screens/Home.ts"),
3185 r#"export const homeTitle = "home";
3186"#,
3187 )
3188 .expect("home should be written");
3189
3190 git(root, &["init", "-b", "main"]);
3191 git(root, &["add", "."]);
3192 git(
3193 root,
3194 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3195 );
3196 fs::write(
3197 root.join("src/App.tsx"),
3198 r#"import { homeTitle } from "@/screens/Home";
3199
3200export function App() {
3201 return homeTitle.toUpperCase();
3202}
3203"#,
3204 )
3205 .expect("app should be modified");
3206
3207 let config_path = None;
3208 let cache_root = root.join(".fallow");
3209 let opts = AuditOptions {
3210 root,
3211 cache_dir: &cache_root,
3212 config_path: &config_path,
3213 output: OutputFormat::Json,
3214 no_cache: true,
3215 threads: 1,
3216 quiet: true,
3217 changed_since: Some("HEAD"),
3218 production: false,
3219 production_dead_code: None,
3220 production_health: None,
3221 production_dupes: None,
3222 workspace: None,
3223 changed_workspaces: None,
3224 explain: false,
3225 explain_skipped: false,
3226 performance: false,
3227 group_by: None,
3228 dead_code_baseline: None,
3229 health_baseline: None,
3230 dupes_baseline: None,
3231 max_crap: None,
3232 coverage: None,
3233 coverage_root: None,
3234 gate: AuditGate::NewOnly,
3235 include_entry_exports: false,
3236 runtime_coverage: None,
3237 min_invocations_hot: 100,
3238 };
3239
3240 let result = execute_audit(&opts).expect("audit should execute");
3241 assert!(
3242 !result.base_snapshot_skipped,
3243 "source diffs should run a real base snapshot"
3244 );
3245 let base = result
3246 .base_snapshot
3247 .as_ref()
3248 .expect("base snapshot should run");
3249 assert!(
3250 !base
3251 .dead_code
3252 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3253 "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
3254 base.dead_code
3255 );
3256 assert!(
3257 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3258 "alias target should stay reachable in the base worktree: {:?}",
3259 base.dead_code
3260 );
3261 let check = result.check.as_ref().expect("dead-code audit should run");
3262 assert!(
3263 check.results.unresolved_imports.is_empty(),
3264 "HEAD audit should also resolve @/* aliases: {:?}",
3265 check.results.unresolved_imports
3266 );
3267 }
3268
3269 #[test]
3270 fn audit_base_preserves_subdirectory_root_resolution() {
3271 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3272 let repo = tmp.path().join("repo");
3273 let root = repo.join("apps/mobile");
3274 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3275 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3276 .expect("node_modules config dir should be created");
3277 fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
3278 .expect("gitignore should be written");
3279 fs::write(
3280 root.join("package.json"),
3281 r#"{
3282 "name": "audit-subdir-react-native-tsconfig-base",
3283 "private": true,
3284 "main": "src/App.tsx",
3285 "dependencies": {
3286 "react-native": "0.80.0"
3287 }
3288 }"#,
3289 )
3290 .expect("package.json should be written");
3291 fs::write(
3292 root.join("tsconfig.json"),
3293 r#"{
3294 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3295 "compilerOptions": {
3296 "baseUrl": ".",
3297 "paths": {
3298 "@/*": ["src/*"]
3299 }
3300 },
3301 "include": ["src/**/*"]
3302 }"#,
3303 )
3304 .expect("tsconfig should be written");
3305 fs::write(
3306 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3307 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3308 )
3309 .expect("react native tsconfig should be written");
3310 fs::write(
3311 root.join("src/App.tsx"),
3312 r#"import { homeTitle } from "@/screens/Home";
3313
3314export function App() {
3315 return homeTitle;
3316}
3317"#,
3318 )
3319 .expect("app should be written");
3320 fs::write(
3321 root.join("src/screens/Home.ts"),
3322 r#"export const homeTitle = "home";
3323"#,
3324 )
3325 .expect("home should be written");
3326
3327 git(&repo, &["init", "-b", "main"]);
3328 git(&repo, &["add", "."]);
3329 git(
3330 &repo,
3331 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3332 );
3333 fs::write(
3334 root.join("src/App.tsx"),
3335 r#"import { homeTitle } from "@/screens/Home";
3336
3337export function App() {
3338 return homeTitle.toUpperCase();
3339}
3340"#,
3341 )
3342 .expect("app should be modified");
3343
3344 let config_path = None;
3345 let cache_root = root.join(".fallow");
3346 let opts = AuditOptions {
3347 root: &root,
3348 cache_dir: &cache_root,
3349 config_path: &config_path,
3350 output: OutputFormat::Json,
3351 no_cache: true,
3352 threads: 1,
3353 quiet: true,
3354 changed_since: Some("HEAD"),
3355 production: false,
3356 production_dead_code: None,
3357 production_health: None,
3358 production_dupes: None,
3359 workspace: None,
3360 changed_workspaces: None,
3361 explain: false,
3362 explain_skipped: false,
3363 performance: false,
3364 group_by: None,
3365 dead_code_baseline: None,
3366 health_baseline: None,
3367 dupes_baseline: None,
3368 max_crap: None,
3369 coverage: None,
3370 coverage_root: None,
3371 gate: AuditGate::NewOnly,
3372 include_entry_exports: false,
3373 runtime_coverage: None,
3374 min_invocations_hot: 100,
3375 };
3376
3377 let result = execute_audit(&opts).expect("audit should execute");
3378 assert!(
3379 !result.base_snapshot_skipped,
3380 "source diffs should run a real base snapshot"
3381 );
3382 let base = result
3383 .base_snapshot
3384 .as_ref()
3385 .expect("base snapshot should run");
3386 assert!(
3387 !base
3388 .dead_code
3389 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3390 "base audit should analyze from the app subdirectory, not the repo root: {:?}",
3391 base.dead_code
3392 );
3393 assert!(
3394 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3395 "subdirectory base audit should keep alias targets reachable: {:?}",
3396 base.dead_code
3397 );
3398 }
3399
3400 #[test]
3401 fn audit_base_uses_new_explicit_config_without_hard_failure() {
3402 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3403 let root = tmp.path();
3404 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3405 fs::write(
3406 root.join("package.json"),
3407 r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
3408 )
3409 .expect("package.json should be written");
3410 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3411 .expect("index should be written");
3412
3413 git(root, &["init", "-b", "main"]);
3414 git(root, &["add", "."]);
3415 git(
3416 root,
3417 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3418 );
3419
3420 let explicit_config = root.join(".fallowrc.json");
3421 fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
3422 .expect("new config should be written");
3423 fs::write(root.join("src/index.ts"), "export const used = 2;\n")
3424 .expect("index should be modified");
3425
3426 let config_path = Some(explicit_config);
3427 let cache_root = root.join(".fallow");
3428 let opts = AuditOptions {
3429 root,
3430 cache_dir: &cache_root,
3431 config_path: &config_path,
3432 output: OutputFormat::Json,
3433 no_cache: true,
3434 threads: 1,
3435 quiet: true,
3436 changed_since: Some("HEAD"),
3437 production: false,
3438 production_dead_code: None,
3439 production_health: None,
3440 production_dupes: None,
3441 workspace: None,
3442 changed_workspaces: None,
3443 explain: false,
3444 explain_skipped: false,
3445 performance: false,
3446 group_by: None,
3447 dead_code_baseline: None,
3448 health_baseline: None,
3449 dupes_baseline: None,
3450 max_crap: None,
3451 coverage: None,
3452 coverage_root: None,
3453 gate: AuditGate::NewOnly,
3454 include_entry_exports: false,
3455 runtime_coverage: None,
3456 min_invocations_hot: 100,
3457 };
3458
3459 let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
3460 assert!(
3461 result.base_snapshot.is_some(),
3462 "base snapshot should use the current explicit config even when the base commit lacks it"
3463 );
3464 }
3465
3466 #[test]
3467 fn audit_base_uses_current_discovered_config_for_attribution() {
3468 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3469 let root = tmp.path();
3470 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3471 fs::write(
3472 root.join("package.json"),
3473 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3474 )
3475 .expect("package.json should be written");
3476 fs::write(
3477 root.join(".fallowrc.json"),
3478 r#"{"rules":{"unused-dependencies":"off"}}"#,
3479 )
3480 .expect("base config should be written");
3481 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3482 .expect("index should be written");
3483
3484 git(root, &["init", "-b", "main"]);
3485 git(root, &["add", "."]);
3486 git(
3487 root,
3488 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3489 );
3490
3491 fs::write(
3492 root.join(".fallowrc.json"),
3493 r#"{"rules":{"unused-dependencies":"error"}}"#,
3494 )
3495 .expect("current config should be written");
3496 fs::write(
3497 root.join("package.json"),
3498 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3499 )
3500 .expect("package.json should be touched");
3501
3502 let config_path = None;
3503 let cache_root = root.join(".fallow");
3504 let opts = AuditOptions {
3505 root,
3506 cache_dir: &cache_root,
3507 config_path: &config_path,
3508 output: OutputFormat::Json,
3509 no_cache: true,
3510 threads: 1,
3511 quiet: true,
3512 changed_since: Some("HEAD"),
3513 production: false,
3514 production_dead_code: None,
3515 production_health: None,
3516 production_dupes: None,
3517 workspace: None,
3518 changed_workspaces: None,
3519 explain: false,
3520 explain_skipped: false,
3521 performance: false,
3522 group_by: None,
3523 dead_code_baseline: None,
3524 health_baseline: None,
3525 dupes_baseline: None,
3526 max_crap: None,
3527 coverage: None,
3528 coverage_root: None,
3529 gate: AuditGate::NewOnly,
3530 include_entry_exports: false,
3531 runtime_coverage: None,
3532 min_invocations_hot: 100,
3533 };
3534
3535 let result = execute_audit(&opts).expect("audit should execute");
3536 assert_eq!(
3537 result.attribution.dead_code_introduced, 0,
3538 "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
3539 result.attribution
3540 );
3541 assert!(
3542 result.attribution.dead_code_inherited > 0,
3543 "pre-existing changed-file findings should be classified as inherited: {:?}",
3544 result.attribution
3545 );
3546 }
3547
3548 #[test]
3549 fn audit_base_current_config_attribution_survives_cache_hit() {
3550 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3551 let root = tmp.path();
3552 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3553 fs::write(
3554 root.join("package.json"),
3555 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3556 )
3557 .expect("package.json should be written");
3558 fs::write(
3559 root.join(".fallowrc.json"),
3560 r#"{"rules":{"unused-dependencies":"off"}}"#,
3561 )
3562 .expect("base config should be written");
3563 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3564 .expect("index should be written");
3565
3566 git(root, &["init", "-b", "main"]);
3567 git(root, &["add", "."]);
3568 git(
3569 root,
3570 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3571 );
3572
3573 fs::write(
3574 root.join(".fallowrc.json"),
3575 r#"{"rules":{"unused-dependencies":"error"}}"#,
3576 )
3577 .expect("current config should be written");
3578 fs::write(
3579 root.join("package.json"),
3580 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3581 )
3582 .expect("package.json should be touched");
3583
3584 let config_path = None;
3585 let cache_root = root.join(".fallow");
3586 let opts = AuditOptions {
3587 root,
3588 cache_dir: &cache_root,
3589 config_path: &config_path,
3590 output: OutputFormat::Json,
3591 no_cache: false,
3592 threads: 1,
3593 quiet: true,
3594 changed_since: Some("HEAD"),
3595 production: false,
3596 production_dead_code: None,
3597 production_health: None,
3598 production_dupes: None,
3599 workspace: None,
3600 changed_workspaces: None,
3601 explain: false,
3602 explain_skipped: false,
3603 performance: false,
3604 group_by: None,
3605 dead_code_baseline: None,
3606 health_baseline: None,
3607 dupes_baseline: None,
3608 max_crap: None,
3609 coverage: None,
3610 coverage_root: None,
3611 gate: AuditGate::NewOnly,
3612 include_entry_exports: false,
3613 runtime_coverage: None,
3614 min_invocations_hot: 100,
3615 };
3616
3617 let first = execute_audit(&opts).expect("first audit should execute");
3618 assert_eq!(
3619 first.attribution.dead_code_introduced, 0,
3620 "first audit should classify pre-existing findings as inherited: {:?}",
3621 first.attribution
3622 );
3623
3624 let changed_files =
3625 crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
3626 let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
3627 .expect("cache key should compute")
3628 .expect("cache key should exist");
3629 assert!(
3630 load_cached_base_snapshot(&opts, &key).is_some(),
3631 "first audit should store a reusable base snapshot"
3632 );
3633
3634 let second = execute_audit(&opts).expect("second audit should execute");
3635 assert_eq!(
3636 second.attribution.dead_code_introduced, 0,
3637 "cache hit should keep current-config attribution stable: {:?}",
3638 second.attribution
3639 );
3640 assert!(
3641 second.attribution.dead_code_inherited > 0,
3642 "cache hit should preserve inherited base findings: {:?}",
3643 second.attribution
3644 );
3645 }
3646
3647 #[test]
3648 fn audit_dupes_only_materializes_groups_touching_changed_files() {
3649 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3650 let root_path = tmp
3651 .path()
3652 .canonicalize()
3653 .expect("temp root should canonicalize");
3654 let root = root_path.as_path();
3655 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3656 fs::write(
3657 root.join("package.json"),
3658 r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
3659 )
3660 .expect("package.json should be written");
3661 fs::write(
3662 root.join(".fallowrc.json"),
3663 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
3664 )
3665 .expect("config should be written");
3666
3667 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";
3668 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";
3669 fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
3670 fs::write(root.join("src/focused-copy.ts"), focused_code)
3671 .expect("focused copy should be written");
3672 fs::write(root.join("src/untouched-a.ts"), untouched_code)
3673 .expect("untouched a should be written");
3674 fs::write(root.join("src/untouched-b.ts"), untouched_code)
3675 .expect("untouched b should be written");
3676
3677 git(root, &["init", "-b", "main"]);
3678 git(root, &["add", "."]);
3679 git(
3680 root,
3681 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3682 );
3683 fs::write(
3684 root.join("src/changed.ts"),
3685 format!("{focused_code}export const changedMarker = true;\n"),
3686 )
3687 .expect("changed file should be modified");
3688
3689 let config_path = None;
3690 let cache_root = root.join(".fallow");
3691 let opts = AuditOptions {
3692 root,
3693 cache_dir: &cache_root,
3694 config_path: &config_path,
3695 output: OutputFormat::Json,
3696 no_cache: true,
3697 threads: 1,
3698 quiet: true,
3699 changed_since: Some("HEAD"),
3700 production: false,
3701 production_dead_code: None,
3702 production_health: None,
3703 production_dupes: None,
3704 workspace: None,
3705 changed_workspaces: None,
3706 explain: false,
3707 explain_skipped: false,
3708 performance: false,
3709 group_by: None,
3710 dead_code_baseline: None,
3711 health_baseline: None,
3712 dupes_baseline: None,
3713 max_crap: None,
3714 coverage: None,
3715 coverage_root: None,
3716 gate: AuditGate::All,
3717 include_entry_exports: false,
3718 runtime_coverage: None,
3719 min_invocations_hot: 100,
3720 };
3721
3722 let result = execute_audit(&opts).expect("audit should execute");
3723 let dupes = result.dupes.expect("dupes should run");
3724 let changed_path = root.join("src/changed.ts");
3725
3726 assert!(
3727 !dupes.report.clone_groups.is_empty(),
3728 "changed file should still match unchanged duplicate code"
3729 );
3730 assert!(dupes.report.clone_groups.iter().all(|group| {
3731 group
3732 .instances
3733 .iter()
3734 .any(|instance| instance.file == changed_path)
3735 }));
3736 }
3737}