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