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