1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::io::{IsTerminal, Write};
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitCode};
6use std::time::{Duration, Instant, SystemTime};
7
8use colored::Colorize;
9use fallow_config::{AuditConfig, AuditGate, OutputFormat};
10use fallow_core::git_env::clear_ambient_git_env;
11use rustc_hash::FxHashSet;
12use xxhash_rust::xxh3::xxh3_64;
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};
18use crate::report;
19use crate::report::plural;
20
21const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 2;
22const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[serde(rename_all = "snake_case")]
28pub enum AuditVerdict {
29 Pass,
31 Warn,
33 Fail,
35}
36
37#[derive(Debug, Clone, serde::Serialize)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40pub struct AuditSummary {
41 pub dead_code_issues: usize,
42 pub dead_code_has_errors: bool,
43 pub complexity_findings: usize,
44 pub max_cyclomatic: Option<u16>,
45 pub duplication_clone_groups: usize,
46}
47
48#[derive(Debug, Default, Clone, serde::Serialize)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51pub struct AuditAttribution {
52 pub gate: AuditGate,
53 pub dead_code_introduced: usize,
54 pub dead_code_inherited: usize,
55 pub complexity_introduced: usize,
56 pub complexity_inherited: usize,
57 pub duplication_introduced: usize,
58 pub duplication_inherited: usize,
59}
60
61pub struct AuditResult {
63 pub verdict: AuditVerdict,
64 pub summary: AuditSummary,
65 pub attribution: AuditAttribution,
66 base_snapshot: Option<AuditKeySnapshot>,
67 pub base_snapshot_skipped: bool,
68 pub changed_files_count: usize,
69 pub changed_files: Vec<PathBuf>,
73 pub base_ref: String,
74 pub head_sha: Option<String>,
75 pub output: OutputFormat,
76 pub performance: bool,
77 pub check: Option<CheckResult>,
78 pub dupes: Option<DupesResult>,
79 pub health: Option<HealthResult>,
80 pub elapsed: Duration,
81}
82
83pub struct AuditOptions<'a> {
84 pub root: &'a std::path::Path,
85 pub config_path: &'a Option<std::path::PathBuf>,
86 pub output: OutputFormat,
87 pub no_cache: bool,
88 pub threads: usize,
89 pub quiet: bool,
90 pub changed_since: Option<&'a str>,
91 pub production: bool,
92 pub production_dead_code: Option<bool>,
93 pub production_health: Option<bool>,
94 pub production_dupes: Option<bool>,
95 pub workspace: Option<&'a [String]>,
96 pub changed_workspaces: Option<&'a str>,
97 pub explain: bool,
98 pub explain_skipped: bool,
99 pub performance: bool,
100 pub group_by: Option<crate::GroupBy>,
101 pub dead_code_baseline: Option<&'a std::path::Path>,
103 pub health_baseline: Option<&'a std::path::Path>,
105 pub dupes_baseline: Option<&'a std::path::Path>,
107 pub max_crap: Option<f64>,
110 pub coverage: Option<&'a std::path::Path>,
112 pub coverage_root: Option<&'a std::path::Path>,
114 pub gate: AuditGate,
115 pub include_entry_exports: bool,
117 pub runtime_coverage: Option<&'a std::path::Path>,
123 pub min_invocations_hot: u64,
125}
126
127fn auto_detect_base_branch(root: &std::path::Path) -> Option<String> {
131 let mut symbolic_ref = std::process::Command::new("git");
132 symbolic_ref
133 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
134 .current_dir(root);
135 clear_ambient_git_env(&mut symbolic_ref);
136 if let Ok(output) = symbolic_ref.output()
137 && output.status.success()
138 {
139 let full_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
140 if let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/") {
141 return Some(branch.to_string());
142 }
143 }
144
145 let mut verify_main = std::process::Command::new("git");
146 verify_main
147 .args(["rev-parse", "--verify", "main"])
148 .current_dir(root);
149 clear_ambient_git_env(&mut verify_main);
150 if let Ok(output) = verify_main.output()
151 && output.status.success()
152 {
153 return Some("main".to_string());
154 }
155
156 let mut verify_master = std::process::Command::new("git");
157 verify_master
158 .args(["rev-parse", "--verify", "master"])
159 .current_dir(root);
160 clear_ambient_git_env(&mut verify_master);
161 if let Ok(output) = verify_master.output()
162 && output.status.success()
163 {
164 return Some("master".to_string());
165 }
166
167 None
168}
169
170fn get_head_sha(root: &std::path::Path) -> Option<String> {
172 let mut command = std::process::Command::new("git");
173 command
174 .args(["rev-parse", "--short", "HEAD"])
175 .current_dir(root);
176 clear_ambient_git_env(&mut command);
177 let output = command.output().ok()?;
178 if output.status.success() {
179 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
180 } else {
181 None
182 }
183}
184
185fn compute_verdict(
186 check: Option<&CheckResult>,
187 dupes: Option<&DupesResult>,
188 health: Option<&HealthResult>,
189) -> AuditVerdict {
190 let mut has_errors = false;
191 let mut has_warnings = false;
192
193 if let Some(result) = check {
194 if crate::check::has_error_severity_issues(
195 &result.results,
196 &result.config.rules,
197 Some(&result.config),
198 ) {
199 has_errors = true;
200 } else if result.results.total_issues() > 0 {
201 has_warnings = true;
202 }
203 }
204
205 if let Some(result) = health
206 && !result.report.findings.is_empty()
207 {
208 has_errors = true;
209 }
210
211 if let Some(result) = dupes
212 && !result.report.clone_groups.is_empty()
213 {
214 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
215 has_errors = true;
216 } else {
217 has_warnings = true;
218 }
219 }
220
221 if has_errors {
222 AuditVerdict::Fail
223 } else if has_warnings {
224 AuditVerdict::Warn
225 } else {
226 AuditVerdict::Pass
227 }
228}
229
230fn build_summary(
231 check: Option<&CheckResult>,
232 dupes: Option<&DupesResult>,
233 health: Option<&HealthResult>,
234) -> AuditSummary {
235 let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
236 let dead_code_has_errors = check.is_some_and(|r| {
237 crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
238 });
239 let complexity_findings = health.map_or(0, |r| r.report.findings.len());
240 let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
241 let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
242
243 AuditSummary {
244 dead_code_issues,
245 dead_code_has_errors,
246 complexity_findings,
247 max_cyclomatic,
248 duplication_clone_groups,
249 }
250}
251
252fn compute_audit_attribution(
253 check: Option<&CheckResult>,
254 dupes: Option<&DupesResult>,
255 health: Option<&HealthResult>,
256 base: Option<&AuditKeySnapshot>,
257 gate: AuditGate,
258) -> AuditAttribution {
259 let dead_code = check
260 .map(|r| {
261 count_introduced(
262 &dead_code_keys(&r.results, &r.config.root),
263 base.map(|b| &b.dead_code),
264 )
265 })
266 .unwrap_or_default();
267 let complexity = health
268 .map(|r| {
269 count_introduced(
270 &health_keys(&r.report, &r.config.root),
271 base.map(|b| &b.health),
272 )
273 })
274 .unwrap_or_default();
275 let duplication = dupes
276 .map(|r| {
277 count_introduced(
278 &dupes_keys(&r.report, &r.config.root),
279 base.map(|b| &b.dupes),
280 )
281 })
282 .unwrap_or_default();
283
284 AuditAttribution {
285 gate,
286 dead_code_introduced: dead_code.0,
287 dead_code_inherited: dead_code.1,
288 complexity_introduced: complexity.0,
289 complexity_inherited: complexity.1,
290 duplication_introduced: duplication.0,
291 duplication_inherited: duplication.1,
292 }
293}
294
295fn compute_introduced_verdict(
296 check: Option<&CheckResult>,
297 dupes: Option<&DupesResult>,
298 health: Option<&HealthResult>,
299 base: Option<&AuditKeySnapshot>,
300) -> AuditVerdict {
301 let mut has_errors = false;
302 let mut has_warnings = false;
303
304 if let Some(result) = check {
305 let base_keys = base.map(|b| &b.dead_code);
306 let mut introduced = result.results.clone();
307 retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
308 if crate::check::has_error_severity_issues(
309 &introduced,
310 &result.config.rules,
311 Some(&result.config),
312 ) {
313 has_errors = true;
314 } else if introduced.total_issues() > 0 {
315 has_warnings = true;
316 }
317 }
318
319 if let Some(result) = health {
320 let base_keys = base.map(|b| &b.health);
321 let introduced = result
322 .report
323 .findings
324 .iter()
325 .filter(|finding| {
326 !base_keys.is_some_and(|keys| {
327 keys.contains(&health_finding_key(finding, &result.config.root))
328 })
329 })
330 .count();
331 if introduced > 0 {
332 has_errors = true;
333 }
334 }
335
336 if let Some(result) = dupes {
337 let base_keys = base.map(|b| &b.dupes);
338 let introduced = result
339 .report
340 .clone_groups
341 .iter()
342 .filter(|group| {
343 !base_keys
344 .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
345 })
346 .count();
347 if introduced > 0 {
348 if result.threshold > 0.0
349 && result.report.stats.duplication_percentage > result.threshold
350 {
351 has_errors = true;
352 } else {
353 has_warnings = true;
354 }
355 }
356 }
357
358 if has_errors {
359 AuditVerdict::Fail
360 } else if has_warnings {
361 AuditVerdict::Warn
362 } else {
363 AuditVerdict::Pass
364 }
365}
366
367struct AuditKeySnapshot {
368 dead_code: FxHashSet<String>,
369 health: FxHashSet<String>,
370 dupes: FxHashSet<String>,
371}
372
373struct AuditBaseSnapshotCacheKey {
374 hash: u64,
375 base_sha: String,
376}
377
378#[derive(bitcode::Encode, bitcode::Decode)]
379struct CachedAuditKeySnapshot {
380 version: u8,
381 cli_version: String,
382 key_hash: u64,
383 base_sha: String,
384 dead_code: Vec<String>,
385 health: Vec<String>,
386 dupes: Vec<String>,
387}
388
389fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
390 let Some(base) = base else {
391 return (0, 0);
392 };
393 keys.iter().fold((0, 0), |(introduced, inherited), key| {
394 if base.contains(key) {
395 (introduced, inherited + 1)
396 } else {
397 (introduced + 1, inherited)
398 }
399 })
400}
401
402fn sorted_keys(keys: &FxHashSet<String>) -> Vec<String> {
403 let mut keys: Vec<String> = keys.iter().cloned().collect();
404 keys.sort_unstable();
405 keys
406}
407
408fn snapshot_from_cached(cached: CachedAuditKeySnapshot) -> AuditKeySnapshot {
409 AuditKeySnapshot {
410 dead_code: cached.dead_code.into_iter().collect(),
411 health: cached.health.into_iter().collect(),
412 dupes: cached.dupes.into_iter().collect(),
413 }
414}
415
416fn cached_from_snapshot(
417 key: &AuditBaseSnapshotCacheKey,
418 snapshot: &AuditKeySnapshot,
419) -> CachedAuditKeySnapshot {
420 CachedAuditKeySnapshot {
421 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
422 cli_version: env!("CARGO_PKG_VERSION").to_string(),
423 key_hash: key.hash,
424 base_sha: key.base_sha.clone(),
425 dead_code: sorted_keys(&snapshot.dead_code),
426 health: sorted_keys(&snapshot.health),
427 dupes: sorted_keys(&snapshot.dupes),
428 }
429}
430
431fn audit_base_snapshot_cache_dir(root: &Path) -> PathBuf {
432 root.join(".fallow")
433 .join("cache")
434 .join(format!("audit-base-v{AUDIT_BASE_SNAPSHOT_CACHE_VERSION}"))
435}
436
437fn audit_base_snapshot_cache_file(root: &Path, key: &AuditBaseSnapshotCacheKey) -> PathBuf {
438 audit_base_snapshot_cache_dir(root).join(format!("{:016x}.bin", key.hash))
439}
440
441fn ensure_audit_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
442 std::fs::create_dir_all(dir)?;
443 let gitignore = dir.join(".gitignore");
444 if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
445 std::fs::write(gitignore, "*\n")?;
446 }
447 Ok(())
448}
449
450fn load_cached_base_snapshot(
451 opts: &AuditOptions<'_>,
452 key: &AuditBaseSnapshotCacheKey,
453) -> Option<AuditKeySnapshot> {
454 let path = audit_base_snapshot_cache_file(opts.root, key);
455 let data = std::fs::read(path).ok()?;
456 if data.len() > MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE {
457 return None;
458 }
459 let cached: CachedAuditKeySnapshot = bitcode::decode(&data).ok()?;
460 if cached.version != AUDIT_BASE_SNAPSHOT_CACHE_VERSION
461 || cached.cli_version != env!("CARGO_PKG_VERSION")
462 || cached.key_hash != key.hash
463 || cached.base_sha != key.base_sha
464 {
465 return None;
466 }
467 Some(snapshot_from_cached(cached))
468}
469
470fn save_cached_base_snapshot(
471 opts: &AuditOptions<'_>,
472 key: &AuditBaseSnapshotCacheKey,
473 snapshot: &AuditKeySnapshot,
474) {
475 let dir = audit_base_snapshot_cache_dir(opts.root);
476 if ensure_audit_base_snapshot_cache_dir(&dir).is_err() {
477 return;
478 }
479 let data = bitcode::encode(&cached_from_snapshot(key, snapshot));
480 let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
481 return;
482 };
483 if tmp.write_all(&data).is_err() {
484 return;
485 }
486 let _ = tmp.persist(audit_base_snapshot_cache_file(opts.root, key));
487}
488
489fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
490 let mut command = Command::new("git");
491 command.args(["rev-parse", rev]).current_dir(root);
492 clear_ambient_git_env(&mut command);
493 let output = command.output().ok()?;
494 if !output.status.success() {
495 return None;
496 }
497 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
498}
499
500fn ambient_git_env_hint() -> Option<String> {
505 use fallow_core::git_env::AMBIENT_GIT_ENV_VARS;
506 for var in AMBIENT_GIT_ENV_VARS {
507 if let Ok(value) = std::env::var(var)
508 && !value.is_empty()
509 {
510 return Some(format!(
511 "{var}={value} is set in the environment; if fallow is being \
512invoked from a git hook this can interfere with worktree operations. Re-run \
513with `env -u {var} fallow audit` to confirm."
514 ));
515 }
516 }
517 None
518}
519
520fn normalized_changed_files(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
521 let git_root = git_toplevel(root);
522 let mut files: Vec<String> = changed_files
523 .iter()
524 .map(|path| {
525 git_root
526 .as_ref()
527 .and_then(|root| path.strip_prefix(root).ok())
528 .unwrap_or(path)
529 .to_string_lossy()
530 .replace('\\', "/")
531 })
532 .collect();
533 files.sort_unstable();
534 files
535}
536
537fn config_file_fingerprint(opts: &AuditOptions<'_>) -> Result<serde_json::Value, ExitCode> {
538 let loaded = if let Some(path) = opts.config_path {
539 let config = fallow_config::FallowConfig::load(path).map_err(|e| {
540 emit_error(
541 &format!("failed to load config '{}': {e}", path.display()),
542 2,
543 opts.output,
544 )
545 })?;
546 Some((config, path.clone()))
547 } else {
548 fallow_config::FallowConfig::find_and_load(opts.root)
549 .map_err(|e| emit_error(&e, 2, opts.output))?
550 };
551
552 let Some((config, path)) = loaded else {
553 return Ok(serde_json::json!({
554 "path": null,
555 "resolved_hash": null,
556 }));
557 };
558 let bytes = serde_json::to_vec(&config).map_err(|e| {
559 emit_error(
560 &format!("failed to serialize resolved config for audit cache key: {e}"),
561 2,
562 opts.output,
563 )
564 })?;
565 Ok(serde_json::json!({
566 "path": path.to_string_lossy(),
567 "resolved_hash": format!("{:016x}", xxh3_64(&bytes)),
568 }))
569}
570
571fn coverage_file_fingerprint(path: &Path, project_root: &Path) -> serde_json::Value {
572 let resolved = crate::health::scoring::resolve_relative_to_root(path, Some(project_root));
573 let file_path = if resolved.is_dir() {
574 resolved.join("coverage-final.json")
575 } else {
576 resolved
577 };
578 match std::fs::read(&file_path) {
579 Ok(bytes) => serde_json::json!({
580 "path": path.to_string_lossy(),
581 "resolved_path": file_path.to_string_lossy(),
582 "content_hash": format!("{:016x}", xxh3_64(&bytes)),
583 "len": bytes.len(),
584 }),
585 Err(err) => serde_json::json!({
586 "path": path.to_string_lossy(),
587 "resolved_path": file_path.to_string_lossy(),
588 "error": err.kind().to_string(),
589 }),
590 }
591}
592
593fn audit_base_snapshot_cache_key(
594 opts: &AuditOptions<'_>,
595 base_ref: &str,
596 changed_files: &FxHashSet<PathBuf>,
597) -> Result<Option<AuditBaseSnapshotCacheKey>, ExitCode> {
598 if opts.no_cache {
599 return Ok(None);
600 }
601 let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
602 return Ok(None);
603 };
604 let config_file = config_file_fingerprint(opts)?;
605 let coverage_file = opts
606 .coverage
607 .map(|p| coverage_file_fingerprint(p, opts.root));
608 let payload = serde_json::json!({
609 "cache_version": AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
610 "cli_version": env!("CARGO_PKG_VERSION"),
611 "base_sha": base_sha,
612 "config_file": config_file,
613 "changed_files": normalized_changed_files(opts.root, changed_files),
614 "production": opts.production,
615 "production_dead_code": opts.production_dead_code,
616 "production_health": opts.production_health,
617 "production_dupes": opts.production_dupes,
618 "workspace": opts.workspace,
619 "changed_workspaces": opts.changed_workspaces,
620 "group_by": opts.group_by.map(|g| format!("{g:?}")),
621 "include_entry_exports": opts.include_entry_exports,
622 "max_crap": opts.max_crap,
623 "coverage": coverage_file,
624 "coverage_root": opts.coverage_root.map(|p| p.to_string_lossy().to_string()),
625 "dead_code_baseline": opts.dead_code_baseline.map(|p| p.to_string_lossy().to_string()),
626 "health_baseline": opts.health_baseline.map(|p| p.to_string_lossy().to_string()),
627 "dupes_baseline": opts.dupes_baseline.map(|p| p.to_string_lossy().to_string()),
628 });
629 let bytes = serde_json::to_vec(&payload).map_err(|e| {
630 emit_error(
631 &format!("failed to build audit cache key: {e}"),
632 2,
633 opts.output,
634 )
635 })?;
636 Ok(Some(AuditBaseSnapshotCacheKey {
637 hash: xxh3_64(&bytes),
638 base_sha,
639 }))
640}
641
642fn compute_base_snapshot(
643 opts: &AuditOptions<'_>,
644 base_ref: &str,
645 changed_files: &FxHashSet<PathBuf>,
646 base_sha: Option<&str>,
647) -> Result<AuditKeySnapshot, ExitCode> {
648 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
649 use std::fmt::Write as _;
650 let mut message =
651 format!("could not create a temporary worktree for base ref '{base_ref}'");
652 if let Some(hint) = ambient_git_env_hint() {
653 let _ = write!(message, "\n hint: {hint}");
654 }
655 return Err(emit_error(&message, 2, opts.output));
656 };
657 let base_root = base_analysis_root(opts.root, worktree.path());
658 let current_config_path = opts
659 .config_path
660 .clone()
661 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
662 let base_opts = AuditOptions {
663 root: &base_root,
664 config_path: ¤t_config_path,
665 output: opts.output,
666 no_cache: opts.no_cache,
667 threads: opts.threads,
668 quiet: true,
669 changed_since: None,
670 production: opts.production,
671 production_dead_code: opts.production_dead_code,
672 production_health: opts.production_health,
673 production_dupes: opts.production_dupes,
674 workspace: opts.workspace,
675 changed_workspaces: None,
676 explain: false,
677 explain_skipped: false,
678 performance: false,
679 group_by: opts.group_by,
680 dead_code_baseline: None,
681 health_baseline: None,
682 dupes_baseline: None,
683 max_crap: opts.max_crap,
684 coverage: opts.coverage,
685 coverage_root: opts.coverage_root,
686 gate: AuditGate::All,
687 include_entry_exports: opts.include_entry_exports,
688 runtime_coverage: None,
689 min_invocations_hot: opts.min_invocations_hot,
690 };
691
692 let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
693 let check_production = opts.production_dead_code.unwrap_or(opts.production);
694 let health_production = opts.production_health.unwrap_or(opts.production);
695 let share_dead_code_parse_with_health = check_production == health_production;
696
697 let (check_res, dupes_res) = rayon::join(
698 || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
699 || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
700 );
701 let mut check = check_res?;
702 let dupes = dupes_res?;
703 let shared_parse = if share_dead_code_parse_with_health {
704 check.as_mut().and_then(|r| r.shared_parse.take())
705 } else {
706 None
707 };
708 let health = run_audit_health(&base_opts, None, shared_parse)?;
709 if let Some(ref mut check) = check {
710 check.shared_parse = None;
711 }
712
713 Ok(AuditKeySnapshot {
714 dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
715 dead_code_keys(&r.results, &r.config.root)
716 }),
717 health: health.as_ref().map_or_else(FxHashSet::default, |r| {
718 health_keys(&r.report, &r.config.root)
719 }),
720 dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
721 dupes_keys(&r.report, &r.config.root)
722 }),
723 })
724}
725
726fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
727 let Some(git_root) = git_toplevel(current_root) else {
728 return base_worktree_root.to_path_buf();
729 };
730 let current_root =
731 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
732 match current_root.strip_prefix(&git_root) {
733 Ok(relative) => base_worktree_root.join(relative),
734 Err(err) => {
735 tracing::warn!(
736 current_root = %current_root.display(),
737 git_root = %git_root.display(),
738 error = %err,
739 "Could not remap audit base root into the base worktree; falling back to worktree root"
740 );
741 base_worktree_root.to_path_buf()
742 }
743 }
744}
745
746fn current_keys_as_base_keys(
747 check: Option<&CheckResult>,
748 dupes: Option<&DupesResult>,
749 health: Option<&HealthResult>,
750) -> AuditKeySnapshot {
751 AuditKeySnapshot {
752 dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
753 dead_code_keys(&r.results, &r.config.root)
754 }),
755 health: health.as_ref().map_or_else(FxHashSet::default, |r| {
756 health_keys(&r.report, &r.config.root)
757 }),
758 dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
759 dupes_keys(&r.report, &r.config.root)
760 }),
761 }
762}
763
764fn can_reuse_current_as_base(
765 opts: &AuditOptions<'_>,
766 base_ref: &str,
767 changed_files: &FxHashSet<PathBuf>,
768) -> bool {
769 let Some(git_root) = git_toplevel(opts.root) else {
770 return false;
771 };
772 let cache_dir = opts.root.join(".fallow");
773 let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
774 changed_files.iter().all(|path| {
775 if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
776 return true;
777 }
778 if !is_analysis_input(path) {
779 return is_non_behavioral_doc(path);
780 }
781 let Ok(current) = std::fs::read_to_string(path) else {
782 return false;
783 };
784 let Some(relative) = path.strip_prefix(&git_root).ok() else {
785 return false;
786 };
787 let Some(base) = git_show_file(opts.root, base_ref, relative) else {
788 return false;
789 };
790 if current == base {
791 return true;
792 }
793 js_ts_tokens_equivalent(path, ¤t, &base)
794 })
795}
796
797fn is_fallow_cache_artifact(
798 path: &Path,
799 cache_dir: &Path,
800 canonical_cache_dir: Option<&Path>,
801) -> bool {
802 path.starts_with(cache_dir)
803 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
804}
805
806fn git_toplevel(root: &Path) -> Option<PathBuf> {
807 let mut command = Command::new("git");
808 command
809 .args(["rev-parse", "--show-toplevel"])
810 .current_dir(root);
811 clear_ambient_git_env(&mut command);
812 let output = command.output().ok()?;
813 if !output.status.success() {
814 return None;
815 }
816 let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
817 Some(dunce::canonicalize(&path).unwrap_or(path))
818}
819
820fn git_show_file(root: &Path, base_ref: &str, relative: &Path) -> Option<String> {
821 let spec = format!(
822 "{}:{}",
823 base_ref,
824 relative.to_string_lossy().replace('\\', "/")
825 );
826 let mut command = Command::new("git");
827 command
828 .args(["show", "--end-of-options", &spec])
829 .current_dir(root);
830 clear_ambient_git_env(&mut command);
831 let output = command.output().ok()?;
832 output
833 .status
834 .success()
835 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
836}
837
838fn is_analysis_input(path: &Path) -> bool {
839 matches!(
840 path.extension().and_then(|ext| ext.to_str()),
841 Some(
842 "js" | "jsx"
843 | "ts"
844 | "tsx"
845 | "mjs"
846 | "mts"
847 | "cjs"
848 | "cts"
849 | "vue"
850 | "svelte"
851 | "astro"
852 | "mdx"
853 | "css"
854 | "scss"
855 )
856 )
857}
858
859fn is_non_behavioral_doc(path: &Path) -> bool {
860 matches!(
861 path.extension().and_then(|ext| ext.to_str()),
862 Some("md" | "markdown" | "txt" | "rst" | "adoc")
863 )
864}
865
866fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
867 if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
868 return false;
869 }
870 if !matches!(
871 path.extension().and_then(|ext| ext.to_str()),
872 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
873 ) {
874 return false;
875 }
876 let current_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, current, false);
877 let base_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, base, false);
878 current_tokens
879 .tokens
880 .iter()
881 .map(|token| &token.kind)
882 .eq(base_tokens.tokens.iter().map(|token| &token.kind))
883}
884
885fn remap_focus_files(
886 files: &FxHashSet<PathBuf>,
887 from_root: &Path,
888 to_root: &Path,
889) -> Option<FxHashSet<PathBuf>> {
890 let mut remapped = FxHashSet::default();
891 for file in files {
892 if let Ok(relative) = file.strip_prefix(from_root) {
893 remapped.insert(to_root.join(relative));
894 }
895 }
896 if remapped.is_empty() {
897 return None;
898 }
899 Some(remapped)
900}
901
902struct BaseWorktree {
903 repo_root: PathBuf,
904 path: PathBuf,
905 persistent: bool,
906}
907
908impl BaseWorktree {
909 fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
910 sweep_orphan_audit_worktrees(repo_root);
911 if let Some(base_sha) = base_sha
912 && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
913 {
914 return Some(worktree);
915 }
916 let path = std::env::temp_dir().join(format!(
917 "fallow-audit-base-{}-{}",
918 std::process::id(),
919 std::time::SystemTime::now()
920 .duration_since(std::time::UNIX_EPOCH)
921 .ok()?
922 .as_nanos()
923 ));
924 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
925 let mut command = Command::new("git");
926 command
927 .args([
928 "worktree",
929 "add",
930 "--detach",
931 "--quiet",
932 guard.path().to_str()?,
933 base_ref,
934 ])
935 .current_dir(repo_root);
936 clear_ambient_git_env(&mut command);
937 let output = crate::signal::scoped_child::output(&mut command).ok()?;
938 if !output.status.success() {
939 return None;
940 }
941 guard.defuse();
942 drop(guard);
943 let worktree = Self {
944 repo_root: repo_root.to_path_buf(),
945 path,
946 persistent: false,
947 };
948 materialize_base_dependency_context(repo_root, worktree.path());
949 Some(worktree)
950 }
951
952 fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
953 let path = reusable_audit_worktree_path(repo_root, base_sha);
954 let _lock = ReusableWorktreeLock::try_acquire(&path)?;
955
956 if reusable_audit_worktree_is_ready(repo_root, &path, base_sha) {
957 let worktree = Self {
958 repo_root: repo_root.to_path_buf(),
959 path,
960 persistent: true,
961 };
962 materialize_base_dependency_context(repo_root, worktree.path());
963 touch_last_used(worktree.path());
964 return Some(worktree);
965 }
966
967 remove_audit_worktree(repo_root, &path);
968 let _ = std::fs::remove_dir_all(&path);
969 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
970 let mut command = Command::new("git");
971 command
972 .args([
973 "worktree",
974 "add",
975 "--detach",
976 "--quiet",
977 guard.path().to_string_lossy().as_ref(),
978 base_sha,
979 ])
980 .current_dir(repo_root);
981 clear_ambient_git_env(&mut command);
982 let output = crate::signal::scoped_child::output(&mut command).ok()?;
983 if !output.status.success() {
984 return None;
985 }
986 guard.defuse();
987 drop(guard);
988
989 let worktree = Self {
990 repo_root: repo_root.to_path_buf(),
991 path,
992 persistent: true,
993 };
994 materialize_base_dependency_context(repo_root, worktree.path());
995 touch_last_used(worktree.path());
996 Some(worktree)
997 }
998
999 fn path(&self) -> &Path {
1000 &self.path
1001 }
1002}
1003
1004struct WorktreeCleanupGuard<'a> {
1016 repo_root: PathBuf,
1017 path: &'a Path,
1018 armed: bool,
1019}
1020
1021impl<'a> WorktreeCleanupGuard<'a> {
1022 fn new(repo_root: &Path, path: &'a Path) -> Self {
1023 Self {
1024 repo_root: repo_root.to_path_buf(),
1025 path,
1026 armed: true,
1027 }
1028 }
1029
1030 fn path(&self) -> &Path {
1031 self.path
1032 }
1033
1034 fn defuse(&mut self) {
1037 self.armed = false;
1038 }
1039}
1040
1041impl Drop for WorktreeCleanupGuard<'_> {
1042 fn drop(&mut self) {
1043 if self.armed {
1044 remove_audit_worktree(&self.repo_root, self.path);
1045 let _ = std::fs::remove_dir_all(self.path);
1046 }
1047 }
1048}
1049
1050struct ReusableWorktreeLock {
1056 _file: std::fs::File,
1057}
1058
1059impl ReusableWorktreeLock {
1060 fn try_acquire(reusable_path: &Path) -> Option<Self> {
1061 let lock_path = reusable_worktree_lock_path(reusable_path);
1062 let file = std::fs::OpenOptions::new()
1063 .create(true)
1064 .truncate(false)
1065 .write(true)
1066 .open(&lock_path)
1067 .ok()?;
1068 match file.try_lock() {
1069 Ok(()) => Some(Self { _file: file }),
1070 Err(std::fs::TryLockError::WouldBlock) => {
1071 tracing::debug!(
1072 path = %lock_path.display(),
1073 "reusable audit worktree lock contended; falling back to non-reusable worktree",
1074 );
1075 None
1076 }
1077 Err(std::fs::TryLockError::Error(err)) => {
1078 tracing::debug!(
1079 path = %lock_path.display(),
1080 error = %err,
1081 "could not acquire reusable audit worktree lock; falling back to non-reusable worktree",
1082 );
1083 None
1084 }
1085 }
1086 }
1087}
1088
1089fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
1090 let mut name = reusable_path
1091 .file_name()
1092 .map(std::ffi::OsString::from)
1093 .unwrap_or_default();
1094 name.push(".lock");
1095 reusable_path
1096 .parent()
1097 .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
1098}
1099
1100const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
1102
1103const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
1105
1106const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
1108
1109fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
1114 let mut name = reusable_path
1115 .file_name()
1116 .map(std::ffi::OsString::from)
1117 .unwrap_or_default();
1118 name.push(REUSABLE_LAST_USED_SUFFIX);
1119 reusable_path
1120 .parent()
1121 .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
1122}
1123
1124fn touch_last_used(reusable_path: &Path) {
1132 let last_used = reusable_worktree_last_used_path(reusable_path);
1133 let result = std::fs::OpenOptions::new()
1134 .create(true)
1135 .truncate(false)
1136 .write(true)
1137 .open(&last_used)
1138 .and_then(|file| file.set_modified(SystemTime::now()));
1139 if let Err(err) = result {
1140 tracing::warn!(
1141 path = %last_used.display(),
1142 error = %err,
1143 "failed to touch reusable audit worktree sidecar; staleness signal may not update",
1144 );
1145 }
1146}
1147
1148fn resolve_cache_max_age(opts: &AuditOptions<'_>) -> Option<Duration> {
1155 if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
1156 if let Ok(days) = raw.trim().parse::<u32>() {
1157 return days_to_duration(days);
1158 }
1159 tracing::debug!(
1160 value = %raw,
1161 "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
1162 );
1163 }
1164 if let Some(days) = load_audit_config(opts).and_then(|c| c.cache_max_age_days) {
1165 return days_to_duration(days);
1166 }
1167 days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS)
1168}
1169
1170fn days_to_duration(days: u32) -> Option<Duration> {
1171 if days == 0 {
1172 return None;
1173 }
1174 Some(Duration::from_secs(u64::from(days) * 86_400))
1175}
1176
1177fn load_audit_config(opts: &AuditOptions<'_>) -> Option<AuditConfig> {
1181 if let Some(path) = opts.config_path {
1182 return fallow_config::FallowConfig::load(path)
1183 .ok()
1184 .map(|config| config.audit);
1185 }
1186 fallow_config::FallowConfig::find_and_load(opts.root)
1187 .ok()
1188 .flatten()
1189 .map(|(config, _path)| config.audit)
1190}
1191
1192fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
1223 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1224 return;
1225 };
1226 let now = SystemTime::now();
1227 let mut removed: u32 = 0;
1228 for path in worktrees {
1229 if !is_reusable_audit_worktree_path(&path) {
1230 continue;
1231 }
1232 if !path.exists() {
1240 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
1241 continue;
1242 };
1243 if path.exists() {
1247 continue;
1248 }
1249 remove_audit_worktree(repo_root, &path);
1250 let _ = std::fs::remove_file(reusable_worktree_last_used_path(&path));
1251 removed += 1;
1252 continue;
1253 }
1254 let Some(max_age) = max_age else {
1255 continue;
1256 };
1257 let sidecar = reusable_worktree_last_used_path(&path);
1258 let sidecar_mtime = std::fs::metadata(&sidecar)
1259 .ok()
1260 .and_then(|m| m.modified().ok());
1261 let Some(mtime) = sidecar_mtime else {
1262 touch_last_used(&path);
1263 continue;
1264 };
1265 let Ok(age) = now.duration_since(mtime) else {
1266 continue;
1267 };
1268 if age < max_age {
1269 continue;
1270 }
1271 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
1272 continue;
1273 };
1274 remove_audit_worktree(repo_root, &path);
1275 let dir_removed = match std::fs::remove_dir_all(&path) {
1276 Ok(()) => true,
1277 Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
1278 Err(err) => {
1279 tracing::warn!(
1280 path = %path.display(),
1281 error = %err,
1282 "failed to remove stale reusable audit worktree directory; entry may leak",
1283 );
1284 false
1285 }
1286 };
1287 let _ = std::fs::remove_file(&sidecar);
1288 if dir_removed {
1289 removed += 1;
1290 }
1291 }
1292 if removed == 0 {
1293 return;
1294 }
1295 let mut command = Command::new("git");
1296 command
1297 .args(["worktree", "prune", "--expire=now"])
1298 .current_dir(repo_root);
1299 clear_ambient_git_env(&mut command);
1300 let _ = command.output();
1301 tracing::info!(
1302 count = removed,
1303 "reclaimed stale audit base-snapshot caches",
1304 );
1305 if !quiet {
1306 let s = plural(removed as usize);
1307 let _ = writeln!(
1308 std::io::stderr(),
1309 "fallow: reclaimed {removed} stale base-snapshot cache{s}",
1310 );
1311 }
1312}
1313
1314fn reusable_audit_worktree_path(repo_root: &Path, base_sha: &str) -> PathBuf {
1315 let repo_root = git_toplevel(repo_root).unwrap_or_else(|| repo_root.to_path_buf());
1316 let repo_root = dunce::canonicalize(&repo_root).unwrap_or(repo_root);
1317 let repo_hash = xxh3_64(repo_root.to_string_lossy().as_bytes());
1318 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1319 std::env::temp_dir().join(format!(
1320 "fallow-audit-base-cache-{repo_hash:016x}-{sha_prefix}"
1321 ))
1322}
1323
1324fn reusable_audit_worktree_is_ready(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1325 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1326 return false;
1327 }
1328 git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha)
1329}
1330
1331fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1332 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1333 return false;
1334 };
1335 worktrees.iter().any(|worktree| paths_equal(worktree, path))
1336}
1337
1338fn paths_equal(left: &Path, right: &Path) -> bool {
1339 if left == right {
1340 return true;
1341 }
1342 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
1343 (Ok(left), Ok(right)) => left == right,
1344 _ => false,
1345 }
1346}
1347
1348const MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
1365
1366fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
1367 for &name in MATERIALIZED_CONTEXT_DIRS {
1368 let source = repo_root.join(name);
1369 if !source.is_dir() {
1370 continue;
1371 }
1372
1373 let destination = worktree_path.join(name);
1374 if destination.is_dir() {
1375 continue;
1376 }
1377 if let Ok(metadata) = std::fs::symlink_metadata(&destination) {
1378 if !metadata.file_type().is_symlink() {
1379 continue;
1380 }
1381 let _ = std::fs::remove_file(&destination);
1382 }
1383
1384 let _ = symlink_dependency_dir(&source, &destination);
1385 }
1386}
1387
1388#[cfg(unix)]
1389fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1390 std::os::unix::fs::symlink(source, destination)
1391}
1392
1393#[cfg(windows)]
1394fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1395 std::os::windows::fs::symlink_dir(source, destination)
1396}
1397
1398fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1399 let mut command = Command::new("git");
1400 command
1401 .args([
1402 "worktree",
1403 "remove",
1404 "--force",
1405 path.to_string_lossy().as_ref(),
1406 ])
1407 .current_dir(repo_root);
1408 clear_ambient_git_env(&mut command);
1409 match crate::signal::scoped_child::output(&mut command) {
1410 Ok(output) => {
1411 if !output.status.success() && path.exists() {
1412 let stderr = String::from_utf8_lossy(&output.stderr);
1413 tracing::warn!(
1414 path = %path.display(),
1415 stderr = %stderr.trim(),
1416 "git worktree remove failed; the directory remains and may leak",
1417 );
1418 }
1419 }
1420 Err(err) => {
1421 tracing::warn!(
1422 path = %path.display(),
1423 error = %err,
1424 "git worktree remove subprocess failed to spawn",
1425 );
1426 }
1427 }
1428}
1429
1430fn sweep_orphan_audit_worktrees(repo_root: &Path) {
1431 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1432 return;
1433 };
1434 let mut removed_any = false;
1435 for path in worktrees {
1436 if !is_fallow_audit_worktree_path(&path)
1437 || is_reusable_audit_worktree_path(&path)
1438 || audit_worktree_process_is_alive(&path)
1439 {
1440 continue;
1441 }
1442 remove_audit_worktree(repo_root, &path);
1443 let _ = std::fs::remove_dir_all(&path);
1444 removed_any = true;
1445 }
1446 if removed_any {
1447 let mut command = Command::new("git");
1448 command
1449 .args(["worktree", "prune", "--expire=now"])
1450 .current_dir(repo_root);
1451 clear_ambient_git_env(&mut command);
1452 let _ = command.output();
1453 }
1454}
1455
1456fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
1457 let mut command = Command::new("git");
1458 command
1459 .args(["worktree", "list", "--porcelain"])
1460 .current_dir(repo_root);
1461 clear_ambient_git_env(&mut command);
1462 let output = command.output().ok()?;
1463 if !output.status.success() {
1464 return None;
1465 }
1466 Some(parse_worktree_list(&String::from_utf8_lossy(
1467 &output.stdout,
1468 )))
1469}
1470
1471fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
1472 output
1473 .lines()
1474 .filter_map(|line| line.strip_prefix("worktree "))
1475 .map(PathBuf::from)
1476 .filter(|path| is_fallow_audit_worktree_path(path))
1477 .collect()
1478}
1479
1480fn is_fallow_audit_worktree_path(path: &Path) -> bool {
1481 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1482 return false;
1483 };
1484 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
1485}
1486
1487fn is_reusable_audit_worktree_path(path: &Path) -> bool {
1488 path.file_name()
1489 .and_then(|name| name.to_str())
1490 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
1491}
1492
1493fn path_is_inside_temp_dir(path: &Path) -> bool {
1494 let temp = std::env::temp_dir();
1495 let simple_path = dunce::simplified(path);
1496 let simple_temp = dunce::simplified(&temp);
1497 if simple_path.starts_with(simple_temp) {
1498 return true;
1499 }
1500 let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
1501 return false;
1502 };
1503 let simple_canonical_temp = dunce::simplified(&canonical_temp);
1504 simple_path.starts_with(simple_canonical_temp)
1505 || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
1506 dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
1507 })
1508}
1509
1510fn audit_worktree_process_is_alive(path: &Path) -> bool {
1511 let Some(pid) = path
1512 .file_name()
1513 .and_then(|name| name.to_str())
1514 .and_then(audit_worktree_pid)
1515 else {
1516 return false;
1517 };
1518 process_is_alive(pid)
1519}
1520
1521fn audit_worktree_pid(name: &str) -> Option<u32> {
1522 name.strip_prefix("fallow-audit-base-")?
1523 .split('-')
1524 .next()?
1525 .parse()
1526 .ok()
1527}
1528
1529#[cfg(unix)]
1530pub fn process_is_alive(pid: u32) -> bool {
1531 Command::new("kill")
1532 .args(["-0", &pid.to_string()])
1533 .output()
1534 .is_ok_and(|output| output.status.success())
1535}
1536
1537#[cfg(windows)]
1538pub fn process_is_alive(pid: u32) -> bool {
1539 windows_process::is_alive(pid)
1540}
1541
1542#[cfg(not(any(unix, windows)))]
1543pub fn process_is_alive(_pid: u32) -> bool {
1544 true
1545}
1546
1547#[cfg(windows)]
1548#[allow(
1549 unsafe_code,
1550 reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
1551)]
1552mod windows_process {
1553 use windows_sys::Win32::Foundation::{
1554 CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
1555 WAIT_OBJECT_0,
1556 };
1557 use windows_sys::Win32::System::Threading::{
1558 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
1559 };
1560
1561 struct ProcessHandle(HANDLE);
1565
1566 impl Drop for ProcessHandle {
1567 fn drop(&mut self) {
1568 unsafe {
1572 CloseHandle(self.0);
1573 }
1574 }
1575 }
1576
1577 pub(super) fn is_alive(pid: u32) -> bool {
1585 let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1589 if raw.is_null() {
1590 let err = unsafe { GetLastError() };
1593 #[expect(
1594 clippy::match_same_arms,
1595 reason = "named arm documents the cross-session case"
1596 )]
1597 return match err {
1598 ERROR_INVALID_PARAMETER => false,
1599 ERROR_ACCESS_DENIED => true,
1600 _ => true,
1601 };
1602 }
1603 let handle = ProcessHandle(raw);
1604 let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
1607 wait_result != WAIT_OBJECT_0
1608 }
1609}
1610
1611impl Drop for BaseWorktree {
1612 fn drop(&mut self) {
1613 if self.persistent {
1614 return;
1615 }
1616 remove_audit_worktree(&self.repo_root, &self.path);
1617 let _ = std::fs::remove_dir_all(&self.path);
1618 }
1619}
1620
1621fn relative_key_path(path: &Path, root: &Path) -> String {
1622 let simple_path = dunce::simplified(path);
1623 let simple_root = dunce::simplified(root);
1624 simple_path
1625 .strip_prefix(simple_root)
1626 .unwrap_or(simple_path)
1627 .to_string_lossy()
1628 .replace('\\', "/")
1629}
1630
1631fn dependency_location_key(location: &fallow_core::results::DependencyLocation) -> &'static str {
1632 match location {
1633 fallow_core::results::DependencyLocation::Dependencies => "unused-dependency",
1634 fallow_core::results::DependencyLocation::DevDependencies => "unused-dev-dependency",
1635 fallow_core::results::DependencyLocation::OptionalDependencies => {
1636 "unused-optional-dependency"
1637 }
1638 }
1639}
1640
1641fn unused_dependency_key(item: &fallow_core::results::UnusedDependency, root: &Path) -> String {
1642 format!(
1643 "{}:{}:{}",
1644 dependency_location_key(&item.location),
1645 relative_key_path(&item.path, root),
1646 item.package_name
1647 )
1648}
1649
1650fn unlisted_dependency_key(item: &fallow_core::results::UnlistedDependency, root: &Path) -> String {
1651 let mut sites = item
1652 .imported_from
1653 .iter()
1654 .map(|site| {
1655 format!(
1656 "{}:{}:{}",
1657 relative_key_path(&site.path, root),
1658 site.line,
1659 site.col
1660 )
1661 })
1662 .collect::<Vec<_>>();
1663 sites.sort();
1664 sites.dedup();
1665 format!(
1666 "unlisted-dependency:{}:{}",
1667 item.package_name,
1668 sites.join("|")
1669 )
1670}
1671
1672fn unused_member_key(
1673 rule_id: &str,
1674 item: &fallow_core::results::UnusedMember,
1675 root: &Path,
1676) -> String {
1677 format!(
1678 "{}:{}:{}:{}",
1679 rule_id,
1680 relative_key_path(&item.path, root),
1681 item.parent_name,
1682 item.member_name
1683 )
1684}
1685
1686fn unused_catalog_entry_key(
1687 item: &fallow_core::results::UnusedCatalogEntry,
1688 root: &Path,
1689) -> String {
1690 format!(
1691 "unused-catalog-entry:{}:{}:{}:{}",
1692 relative_key_path(&item.path, root),
1693 item.line,
1694 item.catalog_name,
1695 item.entry_name
1696 )
1697}
1698
1699fn empty_catalog_group_key(item: &fallow_core::results::EmptyCatalogGroup, root: &Path) -> String {
1700 format!(
1701 "empty-catalog-group:{}:{}:{}",
1702 relative_key_path(&item.path, root),
1703 item.line,
1704 item.catalog_name
1705 )
1706}
1707
1708#[expect(
1709 clippy::too_many_lines,
1710 reason = "one key-builder block per issue type keeps the audit-attribution key shape local and easy to audit; the count grows linearly with new issue types"
1711)]
1712fn dead_code_keys(
1713 results: &fallow_core::results::AnalysisResults,
1714 root: &Path,
1715) -> FxHashSet<String> {
1716 let mut keys = FxHashSet::default();
1717 for item in &results.unused_files {
1718 keys.insert(format!(
1719 "unused-file:{}",
1720 relative_key_path(&item.file.path, root)
1721 ));
1722 }
1723 for item in &results.unused_exports {
1724 keys.insert(format!(
1725 "unused-export:{}:{}",
1726 relative_key_path(&item.export.path, root),
1727 item.export.export_name
1728 ));
1729 }
1730 for item in &results.unused_types {
1731 keys.insert(format!(
1732 "unused-type:{}:{}",
1733 relative_key_path(&item.export.path, root),
1734 item.export.export_name
1735 ));
1736 }
1737 for item in &results.private_type_leaks {
1738 keys.insert(format!(
1739 "private-type-leak:{}:{}:{}",
1740 relative_key_path(&item.leak.path, root),
1741 item.leak.export_name,
1742 item.leak.type_name
1743 ));
1744 }
1745 for item in results
1746 .unused_dependencies
1747 .iter()
1748 .map(|f| &f.dep)
1749 .chain(results.unused_dev_dependencies.iter().map(|f| &f.dep))
1750 .chain(results.unused_optional_dependencies.iter().map(|f| &f.dep))
1751 {
1752 keys.insert(unused_dependency_key(item, root));
1753 }
1754 for item in &results.unused_enum_members {
1755 keys.insert(unused_member_key("unused-enum-member", &item.member, root));
1756 }
1757 for item in &results.unused_class_members {
1758 keys.insert(unused_member_key("unused-class-member", &item.member, root));
1759 }
1760 for item in &results.unresolved_imports {
1761 keys.insert(format!(
1762 "unresolved-import:{}:{}",
1763 relative_key_path(&item.import.path, root),
1764 item.import.specifier
1765 ));
1766 }
1767 for item in results.unlisted_dependencies.iter().map(|f| &f.dep) {
1768 keys.insert(unlisted_dependency_key(item, root));
1769 }
1770 for item in &results.duplicate_exports {
1771 let mut locations: Vec<String> = item
1772 .export
1773 .locations
1774 .iter()
1775 .map(|loc| relative_key_path(&loc.path, root))
1776 .collect();
1777 locations.sort();
1778 locations.dedup();
1779 keys.insert(format!(
1780 "duplicate-export:{}:{}",
1781 item.export.export_name,
1782 locations.join("|")
1783 ));
1784 }
1785 for item in &results.type_only_dependencies {
1786 keys.insert(format!(
1787 "type-only-dependency:{}:{}",
1788 relative_key_path(&item.dep.path, root),
1789 item.dep.package_name
1790 ));
1791 }
1792 for item in &results.test_only_dependencies {
1793 keys.insert(format!(
1794 "test-only-dependency:{}:{}",
1795 relative_key_path(&item.dep.path, root),
1796 item.dep.package_name
1797 ));
1798 }
1799 for item in &results.circular_dependencies {
1800 let mut files: Vec<String> = item
1801 .cycle
1802 .files
1803 .iter()
1804 .map(|path| relative_key_path(path, root))
1805 .collect();
1806 files.sort();
1807 keys.insert(format!("circular-dependency:{}", files.join("|")));
1808 }
1809 for item in &results.re_export_cycles {
1810 let kind = match item.cycle.kind {
1811 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
1812 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
1813 };
1814 let mut files: Vec<String> = item
1815 .cycle
1816 .files
1817 .iter()
1818 .map(|path| relative_key_path(path, root))
1819 .collect();
1820 files.sort();
1821 keys.insert(format!("re-export-cycle:{kind}:{}", files.join("|")));
1822 }
1823 for item in &results.boundary_violations {
1824 keys.insert(format!(
1825 "boundary-violation:{}:{}:{}",
1826 relative_key_path(&item.violation.from_path, root),
1827 relative_key_path(&item.violation.to_path, root),
1828 item.violation.import_specifier
1829 ));
1830 }
1831 for item in &results.stale_suppressions {
1832 keys.insert(format!(
1833 "stale-suppression:{}:{}",
1834 relative_key_path(&item.path, root),
1835 item.description()
1836 ));
1837 }
1838 for item in &results.unresolved_catalog_references {
1839 keys.insert(format!(
1840 "unresolved-catalog-reference:{}:{}:{}:{}",
1841 relative_key_path(&item.reference.path, root),
1842 item.reference.line,
1843 item.reference.catalog_name,
1844 item.reference.entry_name
1845 ));
1846 }
1847 for item in &results.unused_catalog_entries {
1848 keys.insert(unused_catalog_entry_key(&item.entry, root));
1849 }
1850 for item in &results.empty_catalog_groups {
1851 keys.insert(empty_catalog_group_key(&item.group, root));
1852 }
1853 for item in &results.unused_dependency_overrides {
1854 keys.insert(format!(
1855 "unused-dependency-override:{}:{}:{}",
1856 relative_key_path(&item.entry.path, root),
1857 item.entry.line,
1858 item.entry.raw_key
1859 ));
1860 }
1861 for item in &results.misconfigured_dependency_overrides {
1862 keys.insert(format!(
1863 "misconfigured-dependency-override:{}:{}:{}",
1864 relative_key_path(&item.entry.path, root),
1865 item.entry.line,
1866 item.entry.raw_key
1867 ));
1868 }
1869 keys
1870}
1871
1872#[expect(
1873 clippy::too_many_lines,
1874 reason = "one retain block per issue type keeps the gate-filter local and grep-friendly; the count grows linearly with new issue types and parallels dead_code_keys"
1875)]
1876fn retain_introduced_dead_code(
1877 results: &mut fallow_core::results::AnalysisResults,
1878 root: &Path,
1879 base: Option<&FxHashSet<String>>,
1880) {
1881 let Some(base) = base else {
1882 return;
1883 };
1884 results.unused_files.retain(|item| {
1885 !base.contains(&format!(
1886 "unused-file:{}",
1887 relative_key_path(&item.file.path, root)
1888 ))
1889 });
1890 results.unused_exports.retain(|item| {
1891 !base.contains(&format!(
1892 "unused-export:{}:{}",
1893 relative_key_path(&item.export.path, root),
1894 item.export.export_name
1895 ))
1896 });
1897 results.unused_types.retain(|item| {
1898 !base.contains(&format!(
1899 "unused-type:{}:{}",
1900 relative_key_path(&item.export.path, root),
1901 item.export.export_name
1902 ))
1903 });
1904 let introduced = dead_code_keys(results, root)
1905 .into_iter()
1906 .filter(|key| !base.contains(key))
1907 .collect::<FxHashSet<_>>();
1908 let keep = |key: String| introduced.contains(&key);
1909 results.private_type_leaks.retain(|item| {
1910 keep(format!(
1911 "private-type-leak:{}:{}:{}",
1912 relative_key_path(&item.leak.path, root),
1913 item.leak.export_name,
1914 item.leak.type_name
1915 ))
1916 });
1917 results
1918 .unused_dependencies
1919 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1920 results
1921 .unused_dev_dependencies
1922 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1923 results
1924 .unused_optional_dependencies
1925 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1926 results
1927 .unused_enum_members
1928 .retain(|item| keep(unused_member_key("unused-enum-member", &item.member, root)));
1929 results
1930 .unused_class_members
1931 .retain(|item| keep(unused_member_key("unused-class-member", &item.member, root)));
1932 results.unresolved_imports.retain(|item| {
1933 keep(format!(
1934 "unresolved-import:{}:{}",
1935 relative_key_path(&item.import.path, root),
1936 item.import.specifier
1937 ))
1938 });
1939 results
1940 .unlisted_dependencies
1941 .retain(|item| keep(unlisted_dependency_key(&item.dep, root)));
1942 results.duplicate_exports.retain(|item| {
1943 let mut locations: Vec<String> = item
1944 .export
1945 .locations
1946 .iter()
1947 .map(|loc| relative_key_path(&loc.path, root))
1948 .collect();
1949 locations.sort();
1950 locations.dedup();
1951 keep(format!(
1952 "duplicate-export:{}:{}",
1953 item.export.export_name,
1954 locations.join("|")
1955 ))
1956 });
1957 results.type_only_dependencies.retain(|item| {
1958 keep(format!(
1959 "type-only-dependency:{}:{}",
1960 relative_key_path(&item.dep.path, root),
1961 item.dep.package_name
1962 ))
1963 });
1964 results.test_only_dependencies.retain(|item| {
1965 keep(format!(
1966 "test-only-dependency:{}:{}",
1967 relative_key_path(&item.dep.path, root),
1968 item.dep.package_name
1969 ))
1970 });
1971 results.circular_dependencies.retain(|item| {
1972 let mut files: Vec<String> = item
1973 .cycle
1974 .files
1975 .iter()
1976 .map(|path| relative_key_path(path, root))
1977 .collect();
1978 files.sort();
1979 keep(format!("circular-dependency:{}", files.join("|")))
1980 });
1981 results.re_export_cycles.retain(|item| {
1982 let kind = match item.cycle.kind {
1983 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
1984 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
1985 };
1986 let mut files: Vec<String> = item
1987 .cycle
1988 .files
1989 .iter()
1990 .map(|path| relative_key_path(path, root))
1991 .collect();
1992 files.sort();
1993 keep(format!("re-export-cycle:{kind}:{}", files.join("|")))
1994 });
1995 results.boundary_violations.retain(|item| {
1996 keep(format!(
1997 "boundary-violation:{}:{}:{}",
1998 relative_key_path(&item.violation.from_path, root),
1999 relative_key_path(&item.violation.to_path, root),
2000 item.violation.import_specifier
2001 ))
2002 });
2003 results.stale_suppressions.retain(|item| {
2004 keep(format!(
2005 "stale-suppression:{}:{}",
2006 relative_key_path(&item.path, root),
2007 item.description()
2008 ))
2009 });
2010 results.unresolved_catalog_references.retain(|item| {
2011 keep(format!(
2012 "unresolved-catalog-reference:{}:{}:{}:{}",
2013 relative_key_path(&item.reference.path, root),
2014 item.reference.line,
2015 item.reference.catalog_name,
2016 item.reference.entry_name
2017 ))
2018 });
2019 results
2020 .unused_catalog_entries
2021 .retain(|item| keep(unused_catalog_entry_key(&item.entry, root)));
2022 results
2023 .empty_catalog_groups
2024 .retain(|item| keep(empty_catalog_group_key(&item.group, root)));
2025 results.unused_dependency_overrides.retain(|item| {
2026 keep(format!(
2027 "unused-dependency-override:{}:{}:{}",
2028 relative_key_path(&item.entry.path, root),
2029 item.entry.line,
2030 item.entry.raw_key
2031 ))
2032 });
2033 results.misconfigured_dependency_overrides.retain(|item| {
2034 keep(format!(
2035 "misconfigured-dependency-override:{}:{}:{}",
2036 relative_key_path(&item.entry.path, root),
2037 item.entry.line,
2038 item.entry.raw_key
2039 ))
2040 });
2041}
2042
2043fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
2044 !base.contains(key)
2045}
2046
2047fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
2048where
2049 I: IntoIterator<Item = bool>,
2050{
2051 let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
2052 return;
2053 };
2054 for (item, introduced) in items.iter_mut().zip(introduced) {
2055 if let serde_json::Value::Object(map) = item {
2056 map.insert("introduced".to_string(), serde_json::json!(introduced));
2057 }
2058 }
2059}
2060
2061#[expect(
2062 clippy::too_many_lines,
2063 reason = "keeps audit attribution keys adjacent to the JSON arrays they annotate"
2064)]
2065fn annotate_dead_code_json(
2066 json: &mut serde_json::Value,
2067 results: &fallow_core::results::AnalysisResults,
2068 root: &Path,
2069 base: &FxHashSet<String>,
2070) {
2071 annotate_issue_array(
2072 json,
2073 "unused_files",
2074 results.unused_files.iter().map(|item| {
2075 issue_was_introduced(
2076 &format!("unused-file:{}", relative_key_path(&item.file.path, root)),
2077 base,
2078 )
2079 }),
2080 );
2081 annotate_issue_array(
2082 json,
2083 "unused_exports",
2084 results.unused_exports.iter().map(|item| {
2085 issue_was_introduced(
2086 &format!(
2087 "unused-export:{}:{}",
2088 relative_key_path(&item.export.path, root),
2089 item.export.export_name
2090 ),
2091 base,
2092 )
2093 }),
2094 );
2095 annotate_issue_array(
2096 json,
2097 "unused_types",
2098 results.unused_types.iter().map(|item| {
2099 issue_was_introduced(
2100 &format!(
2101 "unused-type:{}:{}",
2102 relative_key_path(&item.export.path, root),
2103 item.export.export_name
2104 ),
2105 base,
2106 )
2107 }),
2108 );
2109 annotate_issue_array(
2110 json,
2111 "private_type_leaks",
2112 results.private_type_leaks.iter().map(|item| {
2113 issue_was_introduced(
2114 &format!(
2115 "private-type-leak:{}:{}:{}",
2116 relative_key_path(&item.leak.path, root),
2117 item.leak.export_name,
2118 item.leak.type_name
2119 ),
2120 base,
2121 )
2122 }),
2123 );
2124 annotate_issue_array(
2125 json,
2126 "unused_dependencies",
2127 results
2128 .unused_dependencies
2129 .iter()
2130 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2131 );
2132 annotate_issue_array(
2133 json,
2134 "unused_dev_dependencies",
2135 results
2136 .unused_dev_dependencies
2137 .iter()
2138 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2139 );
2140 annotate_issue_array(
2141 json,
2142 "unused_optional_dependencies",
2143 results
2144 .unused_optional_dependencies
2145 .iter()
2146 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2147 );
2148 annotate_issue_array(
2149 json,
2150 "unused_enum_members",
2151 results.unused_enum_members.iter().map(|item| {
2152 issue_was_introduced(
2153 &unused_member_key("unused-enum-member", &item.member, root),
2154 base,
2155 )
2156 }),
2157 );
2158 annotate_issue_array(
2159 json,
2160 "unused_class_members",
2161 results.unused_class_members.iter().map(|item| {
2162 issue_was_introduced(
2163 &unused_member_key("unused-class-member", &item.member, root),
2164 base,
2165 )
2166 }),
2167 );
2168 annotate_issue_array(
2169 json,
2170 "unresolved_imports",
2171 results.unresolved_imports.iter().map(|item| {
2172 issue_was_introduced(
2173 &format!(
2174 "unresolved-import:{}:{}",
2175 relative_key_path(&item.import.path, root),
2176 item.import.specifier
2177 ),
2178 base,
2179 )
2180 }),
2181 );
2182 annotate_issue_array(
2183 json,
2184 "unlisted_dependencies",
2185 results
2186 .unlisted_dependencies
2187 .iter()
2188 .map(|item| issue_was_introduced(&unlisted_dependency_key(&item.dep, root), base)),
2189 );
2190 annotate_issue_array(
2191 json,
2192 "duplicate_exports",
2193 results.duplicate_exports.iter().map(|item| {
2194 let mut locations: Vec<String> = item
2195 .export
2196 .locations
2197 .iter()
2198 .map(|loc| relative_key_path(&loc.path, root))
2199 .collect();
2200 locations.sort();
2201 locations.dedup();
2202 issue_was_introduced(
2203 &format!(
2204 "duplicate-export:{}:{}",
2205 item.export.export_name,
2206 locations.join("|")
2207 ),
2208 base,
2209 )
2210 }),
2211 );
2212 annotate_issue_array(
2213 json,
2214 "type_only_dependencies",
2215 results.type_only_dependencies.iter().map(|item| {
2216 issue_was_introduced(
2217 &format!(
2218 "type-only-dependency:{}:{}",
2219 relative_key_path(&item.dep.path, root),
2220 item.dep.package_name
2221 ),
2222 base,
2223 )
2224 }),
2225 );
2226 annotate_issue_array(
2227 json,
2228 "test_only_dependencies",
2229 results.test_only_dependencies.iter().map(|item| {
2230 issue_was_introduced(
2231 &format!(
2232 "test-only-dependency:{}:{}",
2233 relative_key_path(&item.dep.path, root),
2234 item.dep.package_name
2235 ),
2236 base,
2237 )
2238 }),
2239 );
2240 annotate_issue_array(
2241 json,
2242 "circular_dependencies",
2243 results.circular_dependencies.iter().map(|item| {
2244 let mut files: Vec<String> = item
2245 .cycle
2246 .files
2247 .iter()
2248 .map(|path| relative_key_path(path, root))
2249 .collect();
2250 files.sort();
2251 issue_was_introduced(&format!("circular-dependency:{}", files.join("|")), base)
2252 }),
2253 );
2254 annotate_issue_array(
2255 json,
2256 "re_export_cycles",
2257 results.re_export_cycles.iter().map(|item| {
2258 let kind = match item.cycle.kind {
2259 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
2260 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
2261 };
2262 let mut files: Vec<String> = item
2263 .cycle
2264 .files
2265 .iter()
2266 .map(|path| relative_key_path(path, root))
2267 .collect();
2268 files.sort();
2269 issue_was_introduced(&format!("re-export-cycle:{kind}:{}", files.join("|")), base)
2270 }),
2271 );
2272 annotate_issue_array(
2273 json,
2274 "boundary_violations",
2275 results.boundary_violations.iter().map(|item| {
2276 issue_was_introduced(
2277 &format!(
2278 "boundary-violation:{}:{}:{}",
2279 relative_key_path(&item.violation.from_path, root),
2280 relative_key_path(&item.violation.to_path, root),
2281 item.violation.import_specifier
2282 ),
2283 base,
2284 )
2285 }),
2286 );
2287 annotate_issue_array(
2288 json,
2289 "stale_suppressions",
2290 results.stale_suppressions.iter().map(|item| {
2291 issue_was_introduced(
2292 &format!(
2293 "stale-suppression:{}:{}",
2294 relative_key_path(&item.path, root),
2295 item.description()
2296 ),
2297 base,
2298 )
2299 }),
2300 );
2301 annotate_issue_array(
2302 json,
2303 "unresolved_catalog_references",
2304 results.unresolved_catalog_references.iter().map(|item| {
2305 issue_was_introduced(
2306 &format!(
2307 "unresolved-catalog-reference:{}:{}:{}:{}",
2308 relative_key_path(&item.reference.path, root),
2309 item.reference.line,
2310 item.reference.catalog_name,
2311 item.reference.entry_name
2312 ),
2313 base,
2314 )
2315 }),
2316 );
2317 annotate_issue_array(
2318 json,
2319 "unused_catalog_entries",
2320 results
2321 .unused_catalog_entries
2322 .iter()
2323 .map(|item| issue_was_introduced(&unused_catalog_entry_key(&item.entry, root), base)),
2324 );
2325 annotate_issue_array(
2326 json,
2327 "empty_catalog_groups",
2328 results
2329 .empty_catalog_groups
2330 .iter()
2331 .map(|item| issue_was_introduced(&empty_catalog_group_key(&item.group, root), base)),
2332 );
2333 annotate_issue_array(
2334 json,
2335 "unused_dependency_overrides",
2336 results.unused_dependency_overrides.iter().map(|item| {
2337 issue_was_introduced(
2338 &format!(
2339 "unused-dependency-override:{}:{}:{}",
2340 relative_key_path(&item.entry.path, root),
2341 item.entry.line,
2342 item.entry.raw_key
2343 ),
2344 base,
2345 )
2346 }),
2347 );
2348 annotate_issue_array(
2349 json,
2350 "misconfigured_dependency_overrides",
2351 results
2352 .misconfigured_dependency_overrides
2353 .iter()
2354 .map(|item| {
2355 issue_was_introduced(
2356 &format!(
2357 "misconfigured-dependency-override:{}:{}:{}",
2358 relative_key_path(&item.entry.path, root),
2359 item.entry.line,
2360 item.entry.raw_key
2361 ),
2362 base,
2363 )
2364 }),
2365 );
2366}
2367
2368fn annotate_health_json(
2369 json: &mut serde_json::Value,
2370 report: &crate::health_types::HealthReport,
2371 root: &Path,
2372 base: &FxHashSet<String>,
2373) {
2374 let Some(items) = json
2375 .get_mut("findings")
2376 .and_then(serde_json::Value::as_array_mut)
2377 else {
2378 return;
2379 };
2380 for (item, finding) in items.iter_mut().zip(&report.findings) {
2381 if let serde_json::Value::Object(map) = item {
2382 map.insert(
2383 "introduced".to_string(),
2384 serde_json::json!(issue_was_introduced(
2385 &health_finding_key(finding, root),
2386 base
2387 )),
2388 );
2389 }
2390 }
2391}
2392
2393fn annotate_dupes_json(
2394 json: &mut serde_json::Value,
2395 report: &fallow_core::duplicates::DuplicationReport,
2396 root: &Path,
2397 base: &FxHashSet<String>,
2398) {
2399 let Some(items) = json
2400 .get_mut("clone_groups")
2401 .and_then(serde_json::Value::as_array_mut)
2402 else {
2403 return;
2404 };
2405 for (item, group) in items.iter_mut().zip(&report.clone_groups) {
2406 if let serde_json::Value::Object(map) = item {
2407 map.insert(
2408 "introduced".to_string(),
2409 serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
2410 );
2411 }
2412 }
2413}
2414
2415fn health_keys(report: &crate::health_types::HealthReport, root: &Path) -> FxHashSet<String> {
2416 report
2417 .findings
2418 .iter()
2419 .map(|finding| health_finding_key(finding, root))
2420 .collect()
2421}
2422
2423fn health_finding_key(finding: &crate::health_types::ComplexityViolation, root: &Path) -> String {
2424 format!(
2425 "complexity:{}:{}:{:?}",
2426 relative_key_path(&finding.path, root),
2427 finding.name,
2428 finding.exceeded
2429 )
2430}
2431
2432fn dupes_keys(
2433 report: &fallow_core::duplicates::DuplicationReport,
2434 root: &Path,
2435) -> FxHashSet<String> {
2436 report
2437 .clone_groups
2438 .iter()
2439 .map(|group| dupe_group_key(group, root))
2440 .collect()
2441}
2442
2443fn dupe_group_key(group: &fallow_core::duplicates::CloneGroup, root: &Path) -> String {
2444 let mut files: Vec<String> = group
2445 .instances
2446 .iter()
2447 .map(|instance| relative_key_path(&instance.file, root))
2448 .collect();
2449 files.sort();
2450 files.dedup();
2451 let mut hasher = DefaultHasher::new();
2452 for instance in &group.instances {
2453 instance.fragment.hash(&mut hasher);
2454 }
2455 format!(
2456 "dupe:{}:{}:{}:{:x}",
2457 files.join("|"),
2458 group.token_count,
2459 group.line_count,
2460 hasher.finish()
2461 )
2462}
2463
2464struct HeadAnalyses {
2469 check: Option<CheckResult>,
2470 dupes: Option<DupesResult>,
2471 health: Option<HealthResult>,
2472}
2473
2474fn run_audit_head_analyses(
2481 opts: &AuditOptions<'_>,
2482 changed_since: Option<&str>,
2483 changed_files: &FxHashSet<PathBuf>,
2484) -> Result<HeadAnalyses, ExitCode> {
2485 let check_production = opts.production_dead_code.unwrap_or(opts.production);
2486 let health_production = opts.production_health.unwrap_or(opts.production);
2487 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
2488 let share_dead_code_parse_with_health = check_production == health_production;
2489 let share_dead_code_files_with_dupes =
2490 share_dead_code_parse_with_health && check_production == dupes_production;
2491
2492 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
2493 let dupes_files = if share_dead_code_files_with_dupes {
2494 check
2495 .as_ref()
2496 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
2497 } else {
2498 None
2499 };
2500 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
2501 let shared_parse = if share_dead_code_parse_with_health {
2502 check.as_mut().and_then(|r| r.shared_parse.take())
2503 } else {
2504 None
2505 };
2506 let health = run_audit_health(opts, changed_since, shared_parse)?;
2507 Ok(HeadAnalyses {
2508 check,
2509 dupes,
2510 health,
2511 })
2512}
2513
2514pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
2516 let start = Instant::now();
2517
2518 let base_ref = resolve_base_ref(opts)?;
2519
2520 sweep_old_reusable_caches(opts.root, resolve_cache_max_age(opts), opts.quiet);
2524
2525 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
2526 return Err(emit_error(
2527 &format!(
2528 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
2529 ),
2530 2,
2531 opts.output,
2532 ));
2533 };
2534 let changed_files_count = changed_files.len();
2535
2536 if changed_files.is_empty() {
2537 return Ok(empty_audit_result(base_ref, opts, start.elapsed()));
2538 }
2539
2540 let changed_since = Some(base_ref.as_str());
2541
2542 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
2543 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
2544 let base_cache_key = if needs_real_base_snapshot {
2545 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
2546 } else {
2547 None
2548 };
2549 let cached_base_snapshot = base_cache_key
2550 .as_ref()
2551 .and_then(|key| load_cached_base_snapshot(opts, key));
2552
2553 let (head_res, base_res) = if needs_real_base_snapshot && cached_base_snapshot.is_none() {
2554 let base_sha = base_cache_key.as_ref().map(|key| key.base_sha.as_str());
2555 let (h, b) = rayon::join(
2556 || run_audit_head_analyses(opts, changed_since, &changed_files),
2557 || compute_base_snapshot(opts, &base_ref, &changed_files, base_sha),
2558 );
2559 (h, Some(b))
2560 } else {
2561 (
2562 run_audit_head_analyses(opts, changed_since, &changed_files),
2563 None,
2564 )
2565 };
2566
2567 let head = head_res?;
2568 let mut check_result = head.check;
2569 let dupes_result = head.dupes;
2570 let health_result = head.health;
2571
2572 let (base_snapshot, base_snapshot_skipped) = if matches!(opts.gate, AuditGate::NewOnly) {
2573 if let Some(snapshot) = cached_base_snapshot {
2574 (Some(snapshot), false)
2575 } else if let Some(base_res) = base_res {
2576 let snapshot = base_res?;
2577 if let Some(ref key) = base_cache_key {
2578 save_cached_base_snapshot(opts, key, &snapshot);
2579 }
2580 (Some(snapshot), false)
2581 } else {
2582 (
2583 Some(current_keys_as_base_keys(
2584 check_result.as_ref(),
2585 dupes_result.as_ref(),
2586 health_result.as_ref(),
2587 )),
2588 true,
2589 )
2590 }
2591 } else {
2592 (None, false)
2593 };
2594 if let Some(ref mut check) = check_result {
2595 check.shared_parse = None;
2596 }
2597 let attribution = compute_audit_attribution(
2598 check_result.as_ref(),
2599 dupes_result.as_ref(),
2600 health_result.as_ref(),
2601 base_snapshot.as_ref(),
2602 opts.gate,
2603 );
2604 let verdict = if matches!(opts.gate, AuditGate::NewOnly) {
2605 compute_introduced_verdict(
2606 check_result.as_ref(),
2607 dupes_result.as_ref(),
2608 health_result.as_ref(),
2609 base_snapshot.as_ref(),
2610 )
2611 } else {
2612 compute_verdict(
2613 check_result.as_ref(),
2614 dupes_result.as_ref(),
2615 health_result.as_ref(),
2616 )
2617 };
2618 let summary = build_summary(
2619 check_result.as_ref(),
2620 dupes_result.as_ref(),
2621 health_result.as_ref(),
2622 );
2623
2624 Ok(AuditResult {
2625 verdict,
2626 summary,
2627 attribution,
2628 base_snapshot,
2629 base_snapshot_skipped,
2630 changed_files_count,
2631 changed_files: changed_files.into_iter().collect(),
2632 base_ref,
2633 head_sha: get_head_sha(opts.root),
2634 output: opts.output,
2635 performance: opts.performance,
2636 check: check_result,
2637 dupes: dupes_result,
2638 health: health_result,
2639 elapsed: start.elapsed(),
2640 })
2641}
2642
2643fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<String, ExitCode> {
2645 if let Some(ref_str) = opts.changed_since {
2646 return Ok(ref_str.to_string());
2647 }
2648 let Some(branch) = auto_detect_base_branch(opts.root) else {
2649 return Err(emit_error(
2650 "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
2651 2,
2652 opts.output,
2653 ));
2654 };
2655 if let Err(e) = crate::validate::validate_git_ref(&branch) {
2656 return Err(emit_error(
2657 &format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"),
2658 2,
2659 opts.output,
2660 ));
2661 }
2662 Ok(branch)
2663}
2664
2665fn empty_audit_result(base_ref: String, opts: &AuditOptions<'_>, elapsed: Duration) -> AuditResult {
2667 AuditResult {
2668 verdict: AuditVerdict::Pass,
2669 summary: AuditSummary {
2670 dead_code_issues: 0,
2671 dead_code_has_errors: false,
2672 complexity_findings: 0,
2673 max_cyclomatic: None,
2674 duplication_clone_groups: 0,
2675 },
2676 attribution: AuditAttribution {
2677 gate: opts.gate,
2678 ..AuditAttribution::default()
2679 },
2680 base_snapshot: None,
2681 base_snapshot_skipped: false,
2682 changed_files_count: 0,
2683 changed_files: Vec::new(),
2684 base_ref,
2685 head_sha: get_head_sha(opts.root),
2686 output: opts.output,
2687 performance: opts.performance,
2688 check: None,
2689 dupes: None,
2690 health: None,
2691 elapsed,
2692 }
2693}
2694
2695fn run_audit_check<'a>(
2697 opts: &'a AuditOptions<'a>,
2698 changed_since: Option<&'a str>,
2699 retain_modules_for_health: bool,
2700) -> Result<Option<CheckResult>, ExitCode> {
2701 let filters = IssueFilters::default();
2702 let trace_opts = TraceOptions {
2703 trace_export: None,
2704 trace_file: None,
2705 trace_dependency: None,
2706 performance: opts.performance,
2707 };
2708 match crate::check::execute_check(&CheckOptions {
2709 root: opts.root,
2710 config_path: opts.config_path,
2711 output: opts.output,
2712 no_cache: opts.no_cache,
2713 threads: opts.threads,
2714 quiet: opts.quiet,
2715 fail_on_issues: false,
2716 filters: &filters,
2717 changed_since,
2718 diff_index: None,
2719 use_shared_diff_index: true,
2720 baseline: opts.dead_code_baseline,
2721 save_baseline: None,
2722 sarif_file: None,
2723 production: opts.production_dead_code.unwrap_or(opts.production),
2724 production_override: opts.production_dead_code,
2725 workspace: opts.workspace,
2726 changed_workspaces: opts.changed_workspaces,
2727 group_by: opts.group_by,
2728 include_dupes: false,
2729 trace_opts: &trace_opts,
2730 explain: opts.explain,
2731 top: None,
2732 file: &[],
2733 include_entry_exports: opts.include_entry_exports,
2734 summary: false,
2735 regression_opts: crate::regression::RegressionOpts {
2736 fail_on_regression: false,
2737 tolerance: crate::regression::Tolerance::Absolute(0),
2738 regression_baseline_file: None,
2739 save_target: crate::regression::SaveRegressionTarget::None,
2740 scoped: true,
2741 quiet: opts.quiet,
2742 output: opts.output,
2743 },
2744 retain_modules_for_health,
2745 defer_performance: false,
2746 }) {
2747 Ok(r) => Ok(Some(r)),
2748 Err(code) => Err(code),
2749 }
2750}
2751
2752fn run_audit_dupes<'a>(
2758 opts: &'a AuditOptions<'a>,
2759 changed_since: Option<&'a str>,
2760 changed_files: Option<&'a FxHashSet<PathBuf>>,
2761 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2762) -> Result<Option<DupesResult>, ExitCode> {
2763 let dupes_cfg = match crate::load_config_for_analysis(
2764 opts.root,
2765 opts.config_path,
2766 opts.output,
2767 opts.no_cache,
2768 opts.threads,
2769 opts.production_dupes
2770 .or_else(|| opts.production.then_some(true)),
2771 opts.quiet,
2772 fallow_config::ProductionAnalysis::Dupes,
2773 ) {
2774 Ok(c) => c.duplicates,
2775 Err(code) => return Err(code),
2776 };
2777 let dupes_opts = DupesOptions {
2778 root: opts.root,
2779 config_path: opts.config_path,
2780 output: opts.output,
2781 no_cache: opts.no_cache,
2782 threads: opts.threads,
2783 quiet: opts.quiet,
2784 mode: Some(DupesMode::from(dupes_cfg.mode)),
2785 min_tokens: Some(dupes_cfg.min_tokens),
2786 min_lines: Some(dupes_cfg.min_lines),
2787 min_occurrences: Some(dupes_cfg.min_occurrences),
2788 threshold: Some(dupes_cfg.threshold),
2789 skip_local: dupes_cfg.skip_local,
2790 cross_language: dupes_cfg.cross_language,
2791 ignore_imports: dupes_cfg.ignore_imports,
2792 top: None,
2793 baseline_path: opts.dupes_baseline,
2794 save_baseline_path: None,
2795 production: opts.production_dupes.unwrap_or(opts.production),
2796 production_override: opts.production_dupes,
2797 trace: None,
2798 changed_since,
2799 diff_index: None,
2800 use_shared_diff_index: true,
2801 changed_files,
2802 workspace: opts.workspace,
2803 changed_workspaces: opts.changed_workspaces,
2804 explain: opts.explain,
2805 explain_skipped: opts.explain_skipped,
2806 summary: false,
2807 group_by: opts.group_by,
2808 performance: false,
2809 };
2810 let dupes_run = if let Some(files) = pre_discovered {
2811 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2812 } else {
2813 crate::dupes::execute_dupes(&dupes_opts)
2814 };
2815 match dupes_run {
2816 Ok(r) => Ok(Some(r)),
2817 Err(code) => Err(code),
2818 }
2819}
2820
2821fn run_audit_health<'a>(
2823 opts: &'a AuditOptions<'a>,
2824 changed_since: Option<&'a str>,
2825 shared_parse: Option<crate::health::SharedParseData>,
2826) -> Result<Option<HealthResult>, ExitCode> {
2827 let runtime_coverage = match opts.runtime_coverage {
2828 Some(path) => match crate::health::coverage::prepare_options(
2829 path,
2830 opts.min_invocations_hot,
2831 None,
2832 None,
2833 opts.output,
2834 ) {
2835 Ok(options) => Some(options),
2836 Err(code) => return Err(code),
2837 },
2838 None => None,
2839 };
2840
2841 let health_opts = HealthOptions {
2842 root: opts.root,
2843 config_path: opts.config_path,
2844 output: opts.output,
2845 no_cache: opts.no_cache,
2846 threads: opts.threads,
2847 quiet: opts.quiet,
2848 max_cyclomatic: None,
2849 max_cognitive: None,
2850 max_crap: opts.max_crap,
2851 top: None,
2852 sort: SortBy::Cyclomatic,
2853 production: opts.production_health.unwrap_or(opts.production),
2854 production_override: opts.production_health,
2855 changed_since,
2856 diff_index: None,
2857 use_shared_diff_index: true,
2858 workspace: opts.workspace,
2859 changed_workspaces: opts.changed_workspaces,
2860 baseline: opts.health_baseline,
2861 save_baseline: None,
2862 complexity: true,
2863 complexity_breakdown: false,
2864 file_scores: false,
2865 coverage_gaps: false,
2866 config_activates_coverage_gaps: false,
2867 hotspots: false,
2868 ownership: false,
2869 ownership_emails: None,
2870 targets: false,
2871 force_full: false,
2872 score_only_output: false,
2873 enforce_coverage_gap_gate: false,
2874 effort: None,
2875 score: false,
2876 min_score: None,
2877 since: None,
2878 min_commits: None,
2879 explain: opts.explain,
2880 summary: false,
2881 save_snapshot: None,
2882 trend: false,
2883 group_by: opts.group_by,
2884 coverage: opts.coverage,
2885 coverage_root: opts.coverage_root,
2886 performance: opts.performance,
2887 min_severity: None,
2888 report_only: false,
2889 runtime_coverage,
2890 churn_file: None,
2892 };
2893 let health_run = if let Some(shared) = shared_parse {
2894 crate::health::execute_health_with_shared_parse(&health_opts, shared)
2895 } else {
2896 crate::health::execute_health(&health_opts)
2897 };
2898 match health_run {
2899 Ok(r) => Ok(Some(r)),
2900 Err(code) => Err(code),
2901 }
2902}
2903
2904#[must_use]
2906pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
2907 let output = result.output;
2908
2909 let format_exit = match output {
2910 OutputFormat::Json => print_audit_json(result),
2911 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
2912 print_audit_human(result, quiet, explain, output);
2913 ExitCode::SUCCESS
2914 }
2915 OutputFormat::Sarif => print_audit_sarif(result),
2916 OutputFormat::CodeClimate => print_audit_codeclimate(result),
2917 OutputFormat::PrCommentGithub => {
2918 let value = build_audit_codeclimate(result);
2919 report::ci::pr_comment::print_pr_comment(
2920 "audit",
2921 report::ci::pr_comment::Provider::Github,
2922 &value,
2923 )
2924 }
2925 OutputFormat::PrCommentGitlab => {
2926 let value = build_audit_codeclimate(result);
2927 report::ci::pr_comment::print_pr_comment(
2928 "audit",
2929 report::ci::pr_comment::Provider::Gitlab,
2930 &value,
2931 )
2932 }
2933 OutputFormat::ReviewGithub => {
2934 let value = build_audit_codeclimate(result);
2935 report::ci::review::print_review_envelope(
2936 "audit",
2937 report::ci::pr_comment::Provider::Github,
2938 &value,
2939 )
2940 }
2941 OutputFormat::ReviewGitlab => {
2942 let value = build_audit_codeclimate(result);
2943 report::ci::review::print_review_envelope(
2944 "audit",
2945 report::ci::pr_comment::Provider::Gitlab,
2946 &value,
2947 )
2948 }
2949 OutputFormat::Badge => {
2950 eprintln!("Error: badge format is not supported for the audit command");
2951 return ExitCode::from(2);
2952 }
2953 };
2954
2955 if format_exit != ExitCode::SUCCESS {
2956 return format_exit;
2957 }
2958
2959 match result.verdict {
2960 AuditVerdict::Fail => ExitCode::from(1),
2961 AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
2962 }
2963}
2964
2965fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
2966 let show_headers = matches!(output, OutputFormat::Human) && !quiet;
2967
2968 if !quiet {
2969 let scope = format_scope_line(result);
2970 eprintln!();
2971 eprintln!("{scope}");
2972 }
2973
2974 let has_check_issues = result.summary.dead_code_issues > 0;
2975 let has_health_findings = result.summary.complexity_findings > 0;
2976 let has_dupe_groups = result.summary.duplication_clone_groups > 0;
2977 let has_any_findings = has_check_issues || has_health_findings || has_dupe_groups;
2978
2979 if has_any_findings {
2980 if show_headers && std::io::stdout().is_terminal() {
2981 println!(
2982 "{}",
2983 "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
2984 .dimmed()
2985 );
2986 println!();
2987 }
2988
2989 if result.verdict != AuditVerdict::Fail && !quiet {
2990 print_audit_vital_signs(result);
2991 }
2992
2993 if has_check_issues && let Some(ref check) = result.check {
2994 if show_headers {
2995 eprintln!();
2996 eprintln!("── Dead Code ──────────────────────────────────────");
2997 }
2998 crate::check::print_check_result(
2999 check,
3000 crate::check::PrintCheckOptions {
3001 quiet,
3002 explain,
3003 regression_json: false,
3004 group_by: None,
3005 top: None,
3006 summary: false,
3007 summary_heading: true,
3008 show_explain_tip: false,
3009 },
3010 );
3011 }
3012
3013 if has_dupe_groups && let Some(ref dupes) = result.dupes {
3014 if show_headers {
3015 eprintln!();
3016 eprintln!("── Duplication ────────────────────────────────────");
3017 }
3018 crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
3019 }
3020
3021 if has_health_findings && let Some(ref health) = result.health {
3022 if show_headers {
3023 eprintln!();
3024 eprintln!("── Complexity ─────────────────────────────────────");
3025 }
3026 crate::health::print_health_result(
3027 health, quiet, explain, None, None, false, false, true, false, false,
3028 );
3029 }
3030 }
3031
3032 if !has_dupe_groups && let Some(ref dupes) = result.dupes {
3033 crate::dupes::print_default_ignore_note(dupes, quiet);
3034 crate::dupes::print_min_occurrences_note(dupes, quiet);
3035 }
3036
3037 if !quiet {
3038 print_audit_status_line(result);
3039 }
3040}
3041
3042fn format_scope_line(result: &AuditResult) -> String {
3044 let sha_suffix = result
3045 .head_sha
3046 .as_ref()
3047 .map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
3048 format!(
3049 "Audit scope: {} changed file{} vs {}{}",
3050 result.changed_files_count,
3051 plural(result.changed_files_count),
3052 result.base_ref,
3053 sha_suffix
3054 )
3055}
3056
3057fn print_audit_vital_signs(result: &AuditResult) {
3059 let mut parts = Vec::new();
3060 parts.push(format!("dead code {}", result.summary.dead_code_issues));
3061 if let Some(max) = result.summary.max_cyclomatic {
3062 parts.push(format!(
3063 "complexity {} (warn, max cyclomatic: {max})",
3064 result.summary.complexity_findings
3065 ));
3066 } else {
3067 parts.push(format!("complexity {}", result.summary.complexity_findings));
3068 }
3069 parts.push(format!(
3070 "duplication {}",
3071 result.summary.duplication_clone_groups
3072 ));
3073
3074 let line = parts.join(" \u{00b7} ");
3075 println!(
3076 "{} {} {}",
3077 "\u{25a0}".dimmed(),
3078 "Metrics:".dimmed(),
3079 line.dimmed()
3080 );
3081}
3082
3083fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
3085 let mut parts = Vec::new();
3086 if summary.dead_code_issues > 0 {
3087 let n = summary.dead_code_issues;
3088 parts.push(format!("dead code: {n} issue{}", plural(n)));
3089 }
3090 if summary.complexity_findings > 0 {
3091 let n = summary.complexity_findings;
3092 parts.push(format!("complexity: {n} finding{}", plural(n)));
3093 }
3094 if summary.duplication_clone_groups > 0 {
3095 let n = summary.duplication_clone_groups;
3096 parts.push(format!("duplication: {n} clone group{}", plural(n)));
3097 }
3098 parts
3099}
3100
3101fn print_audit_status_line(result: &AuditResult) {
3103 let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
3104 let n = result.changed_files_count;
3105 let files_str = format!("{n} changed file{}", plural(n));
3106
3107 match result.verdict {
3108 AuditVerdict::Pass => {
3109 eprintln!(
3110 "{}",
3111 format!("\u{2713} No issues in {files_str} ({elapsed_str})")
3112 .green()
3113 .bold()
3114 );
3115 }
3116 AuditVerdict::Warn => {
3117 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
3118 eprintln!(
3119 "{}",
3120 format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
3121 .green()
3122 .bold()
3123 );
3124 }
3125 AuditVerdict::Fail => {
3126 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
3127 eprintln!(
3128 "{}",
3129 format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
3130 .red()
3131 .bold()
3132 );
3133 }
3134 }
3135
3136 if !matches!(result.attribution.gate, AuditGate::All) {
3137 let inherited = result.attribution.dead_code_inherited
3138 + result.attribution.complexity_inherited
3139 + result.attribution.duplication_inherited;
3140 if inherited > 0 {
3141 eprintln!(
3142 " {}",
3143 format!(
3144 "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
3145 plural(inherited)
3146 )
3147 .dimmed()
3148 );
3149 }
3150 }
3151 if result.performance {
3152 eprintln!(
3153 " {}",
3154 format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
3155 );
3156 }
3157}
3158
3159#[expect(
3160 clippy::cast_possible_truncation,
3161 reason = "elapsed milliseconds won't exceed u64::MAX"
3162)]
3163fn print_audit_json(result: &AuditResult) -> ExitCode {
3164 let mut obj = serde_json::Map::new();
3165 obj.insert(
3166 "schema_version".into(),
3167 serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
3168 );
3169 obj.insert(
3170 "version".into(),
3171 serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
3172 );
3173 obj.insert(
3174 "command".into(),
3175 serde_json::Value::String("audit".to_string()),
3176 );
3177 obj.insert(
3178 "verdict".into(),
3179 serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
3180 );
3181 obj.insert(
3182 "changed_files_count".into(),
3183 serde_json::Value::Number(result.changed_files_count.into()),
3184 );
3185 obj.insert(
3186 "base_ref".into(),
3187 serde_json::Value::String(result.base_ref.clone()),
3188 );
3189 if let Some(ref sha) = result.head_sha {
3190 obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
3191 }
3192 obj.insert(
3193 "elapsed_ms".into(),
3194 serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
3195 );
3196 if result.performance {
3197 obj.insert(
3198 "base_snapshot_skipped".into(),
3199 serde_json::Value::Bool(result.base_snapshot_skipped),
3200 );
3201 }
3202
3203 if let Ok(summary_val) = serde_json::to_value(&result.summary) {
3204 obj.insert("summary".into(), summary_val);
3205 }
3206 if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
3207 obj.insert("attribution".into(), attribution_val);
3208 }
3209
3210 if let Some(ref check) = result.check {
3211 match report::build_check_json_payload_with_config_fixable(
3212 &check.results,
3213 &check.config.root,
3214 check.elapsed,
3215 check.config_fixable,
3216 ) {
3217 Ok(mut json) => {
3218 if let Some(ref base) = result.base_snapshot {
3219 annotate_dead_code_json(
3220 &mut json,
3221 &check.results,
3222 &check.config.root,
3223 &base.dead_code,
3224 );
3225 }
3226 obj.insert("dead_code".into(), json);
3227 }
3228 Err(e) => {
3229 return emit_error(
3230 &format!("JSON serialization error: {e}"),
3231 2,
3232 OutputFormat::Json,
3233 );
3234 }
3235 }
3236 }
3237
3238 if let Some(ref dupes) = result.dupes {
3239 let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
3240 match serde_json::to_value(&payload) {
3241 Ok(mut json) => {
3242 let root_prefix = format!("{}/", dupes.config.root.display());
3243 report::strip_root_prefix(&mut json, &root_prefix);
3244 if let Some(ref base) = result.base_snapshot {
3245 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
3246 }
3247 obj.insert("duplication".into(), json);
3248 }
3249 Err(e) => {
3250 return emit_error(
3251 &format!("JSON serialization error: {e}"),
3252 2,
3253 OutputFormat::Json,
3254 );
3255 }
3256 }
3257 }
3258
3259 if let Some(ref health) = result.health {
3260 match serde_json::to_value(&health.report) {
3261 Ok(mut json) => {
3262 let root_prefix = format!("{}/", health.config.root.display());
3263 report::strip_root_prefix(&mut json, &root_prefix);
3264 if let Some(ref base) = result.base_snapshot {
3265 annotate_health_json(
3266 &mut json,
3267 &health.report,
3268 &health.config.root,
3269 &base.health,
3270 );
3271 }
3272 obj.insert("complexity".into(), json);
3273 }
3274 Err(e) => {
3275 return emit_error(
3276 &format!("JSON serialization error: {e}"),
3277 2,
3278 OutputFormat::Json,
3279 );
3280 }
3281 }
3282 }
3283
3284 let mut output = serde_json::Value::Object(obj);
3285 crate::output_envelope::apply_root_kind(&mut output, "audit");
3286 report::harmonize_multi_kind_suppress_line_actions(&mut output);
3287 report::emit_json(&output, "audit")
3288}
3289
3290fn print_audit_sarif(result: &AuditResult) -> ExitCode {
3291 let mut all_runs = Vec::new();
3292
3293 if let Some(ref check) = result.check {
3294 let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
3295 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
3296 all_runs.extend(runs.iter().cloned());
3297 }
3298 }
3299
3300 if let Some(ref dupes) = result.dupes
3301 && !dupes.report.clone_groups.is_empty()
3302 {
3303 let run = serde_json::json!({
3304 "tool": {
3305 "driver": {
3306 "name": "fallow",
3307 "version": env!("CARGO_PKG_VERSION"),
3308 "informationUri": "https://github.com/fallow-rs/fallow",
3309 }
3310 },
3311 "automationDetails": { "id": "fallow/audit/dupes" },
3312 "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
3313 serde_json::json!({
3314 "ruleId": "fallow/code-duplication",
3315 "level": "warning",
3316 "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
3317 })
3318 }).collect::<Vec<_>>()
3319 });
3320 all_runs.push(run);
3321 }
3322
3323 if let Some(ref health) = result.health {
3324 let sarif = report::build_health_sarif(&health.report, &health.config.root);
3325 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
3326 all_runs.extend(runs.iter().cloned());
3327 }
3328 }
3329
3330 let combined = serde_json::json!({
3331 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3332 "version": "2.1.0",
3333 "runs": all_runs,
3334 });
3335
3336 report::emit_json(&combined, "SARIF audit")
3337}
3338
3339fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
3340 let value = build_audit_codeclimate(result);
3341 report::emit_json(&value, "CodeClimate audit")
3342}
3343
3344#[expect(
3345 clippy::expect_used,
3346 reason = "CodeClimate issue envelope contains only infallibly serializable fields"
3347)]
3348fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
3349 let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
3350
3351 if let Some(ref check) = result.check {
3352 all_issues.extend(report::build_codeclimate(
3353 &check.results,
3354 &check.config.root,
3355 &check.config.rules,
3356 ));
3357 }
3358
3359 if let Some(ref dupes) = result.dupes {
3360 all_issues.extend(report::build_duplication_codeclimate(
3361 &dupes.report,
3362 &dupes.config.root,
3363 ));
3364 }
3365
3366 if let Some(ref health) = result.health {
3367 all_issues.extend(report::build_health_codeclimate(
3368 &health.report,
3369 &health.config.root,
3370 ));
3371 }
3372
3373 serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
3374}
3375
3376pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
3382 if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
3383 return emit_error(&e, 2, opts.output);
3384 }
3385 let coverage_resolved = opts
3386 .coverage
3387 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
3388 let runtime_coverage_resolved = opts
3389 .runtime_coverage
3390 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
3391 let resolved_opts = AuditOptions {
3392 coverage: coverage_resolved.as_deref(),
3393 runtime_coverage: runtime_coverage_resolved.as_deref(),
3394 ..*opts
3395 };
3396 match execute_audit(&resolved_opts) {
3397 Ok(result) => {
3398 let mut findings = result
3399 .check
3400 .as_ref()
3401 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
3402 .unwrap_or_default();
3403 if let Some(health) = result.health.as_ref() {
3404 findings.extend(crate::impact::collect_complexity_findings(&health.report));
3405 }
3406 let clones = result
3407 .dupes
3408 .as_ref()
3409 .map(|d| crate::impact::collect_clone_findings(&d.report))
3410 .unwrap_or_default();
3411 let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
3412 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
3413 c.results.active_suppressions.as_slice()
3414 });
3415 let attribution = crate::impact::AttributionInput {
3416 root: opts.root,
3417 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
3418 findings,
3419 clones,
3420 suppressions,
3421 };
3422 crate::impact::record_audit_run(
3423 opts.root,
3424 &result.summary,
3425 result.verdict,
3426 gate_marker.is_some(),
3427 result.head_sha.as_deref(),
3428 env!("CARGO_PKG_VERSION"),
3429 &crate::vital_signs::chrono_timestamp(),
3430 Some(&attribution),
3431 );
3432 print_audit_result(&result, opts.quiet, opts.explain)
3433 }
3434 Err(code) => code,
3435 }
3436}
3437
3438#[cfg(test)]
3439mod tests {
3440 use super::*;
3441 use std::{fs, process::Command};
3442
3443 fn git(dir: &std::path::Path, args: &[&str]) {
3444 let output = Command::new("git")
3445 .args(args)
3446 .current_dir(dir)
3447 .env_remove("GIT_DIR")
3448 .env_remove("GIT_WORK_TREE")
3449 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3450 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3451 .env("GIT_AUTHOR_NAME", "test")
3452 .env("GIT_AUTHOR_EMAIL", "test@test.com")
3453 .env("GIT_COMMITTER_NAME", "test")
3454 .env("GIT_COMMITTER_EMAIL", "test@test.com")
3455 .output()
3456 .expect("git command failed");
3457 assert!(
3458 output.status.success(),
3459 "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
3460 args,
3461 String::from_utf8_lossy(&output.stdout),
3462 String::from_utf8_lossy(&output.stderr)
3463 );
3464 }
3465
3466 #[test]
3467 fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
3468 let temp = std::env::temp_dir();
3469 let audit_path = temp.join("fallow-audit-base-123-456");
3470 let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
3471 let canonical_audit_path = temp
3472 .canonicalize()
3473 .unwrap_or_else(|_| temp.clone())
3474 .join("fallow-audit-base-456-789");
3475 let unrelated_temp = temp.join("other-worktree");
3476 let output = format!(
3477 "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
3478 audit_path.display(),
3479 unrelated_temp.display(),
3480 reusable_path.display()
3481 );
3482
3483 assert_eq!(
3484 parse_worktree_list(&output),
3485 vec![audit_path, reusable_path.clone()]
3486 );
3487 assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
3488 assert!(is_reusable_audit_worktree_path(&reusable_path));
3489 assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
3490 assert_eq!(
3491 audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
3492 None
3493 );
3494 assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
3495 }
3496
3497 fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
3501 let root = parent.join(name);
3502 fs::create_dir_all(&root).expect("repo root should be created");
3503 fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
3504 git(&root, &["init", "-b", "main"]);
3505 git(&root, &["add", "."]);
3506 git(
3507 &root,
3508 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3509 );
3510 root
3511 }
3512
3513 fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3514 list_audit_worktrees(repo_root)
3515 .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
3516 }
3517
3518 fn worktree_admin_entry_present(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3526 let basename = worktree_path
3527 .file_name()
3528 .and_then(|n| n.to_str())
3529 .expect("reusable worktree path has a utf-8 basename");
3530 let output = Command::new("git")
3531 .args(["worktree", "list", "--porcelain"])
3532 .current_dir(repo_root)
3533 .env_remove("GIT_DIR")
3534 .env_remove("GIT_WORK_TREE")
3535 .output()
3536 .expect("git worktree list should run");
3537 String::from_utf8_lossy(&output.stdout)
3538 .lines()
3539 .filter_map(|line| line.strip_prefix("worktree "))
3540 .any(|p| p.ends_with(basename))
3541 }
3542
3543 #[test]
3544 fn worktree_cleanup_guard_runs_on_drop() {
3545 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3546 let repo = init_throwaway_repo(tmp.path(), "repo");
3547 let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
3548
3549 git(
3550 &repo,
3551 &[
3552 "worktree",
3553 "add",
3554 "--detach",
3555 "--quiet",
3556 worktree_path.to_str().expect("path is utf-8"),
3557 "HEAD",
3558 ],
3559 );
3560 assert!(worktree_path.is_dir());
3561 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3562
3563 {
3564 let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3565 }
3566
3567 assert!(
3568 !worktree_path.exists(),
3569 "guard Drop should remove the worktree directory",
3570 );
3571 assert!(
3572 !worktree_is_registered_with_git(&repo, &worktree_path),
3573 "guard Drop should remove the git worktree registration",
3574 );
3575 }
3576
3577 #[test]
3578 fn worktree_cleanup_guard_defused_skips_drop() {
3579 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3580 let repo = init_throwaway_repo(tmp.path(), "repo");
3581 let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
3582
3583 git(
3584 &repo,
3585 &[
3586 "worktree",
3587 "add",
3588 "--detach",
3589 "--quiet",
3590 worktree_path.to_str().expect("path is utf-8"),
3591 "HEAD",
3592 ],
3593 );
3594 assert!(worktree_path.is_dir());
3595
3596 {
3597 let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3598 guard.defuse();
3599 guard.defuse();
3600 }
3601
3602 assert!(
3603 worktree_path.is_dir(),
3604 "defused guard must not remove the worktree on drop",
3605 );
3606 assert!(
3607 worktree_is_registered_with_git(&repo, &worktree_path),
3608 "defused guard must not unregister the worktree from git",
3609 );
3610
3611 remove_audit_worktree(&repo, &worktree_path);
3612 let _ = fs::remove_dir_all(&worktree_path);
3613 }
3614
3615 #[test]
3616 fn audit_orphan_sweep_removes_dead_pid_worktree() {
3617 const DEAD_PID: u32 = 99_999_999;
3618 assert!(!process_is_alive(DEAD_PID));
3619
3620 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3621 let repo = init_throwaway_repo(tmp.path(), "repo");
3622
3623 let worktree_path = std::env::temp_dir().join(format!(
3624 "fallow-audit-base-{}-{}",
3625 DEAD_PID,
3626 std::time::SystemTime::now()
3627 .duration_since(std::time::UNIX_EPOCH)
3628 .expect("clock should be after epoch")
3629 .as_nanos()
3630 ));
3631 git(
3632 &repo,
3633 &[
3634 "worktree",
3635 "add",
3636 "--detach",
3637 "--quiet",
3638 worktree_path.to_str().expect("path is utf-8"),
3639 "HEAD",
3640 ],
3641 );
3642 assert!(worktree_path.is_dir());
3643 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3644
3645 sweep_orphan_audit_worktrees(&repo);
3646
3647 assert!(
3648 !worktree_path.exists(),
3649 "sweep should remove worktree owned by a dead PID",
3650 );
3651 assert!(
3652 !worktree_is_registered_with_git(&repo, &worktree_path),
3653 "sweep should unregister worktree owned by a dead PID",
3654 );
3655 }
3656
3657 #[test]
3658 fn audit_orphan_sweep_keeps_live_pid_worktree() {
3659 let live_pid = std::process::id();
3660 assert!(process_is_alive(live_pid));
3661
3662 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3663 let repo = init_throwaway_repo(tmp.path(), "repo");
3664
3665 let worktree_path = std::env::temp_dir().join(format!(
3666 "fallow-audit-base-{}-{}",
3667 live_pid,
3668 std::time::SystemTime::now()
3669 .duration_since(std::time::UNIX_EPOCH)
3670 .expect("clock should be after epoch")
3671 .as_nanos()
3672 ));
3673 git(
3674 &repo,
3675 &[
3676 "worktree",
3677 "add",
3678 "--detach",
3679 "--quiet",
3680 worktree_path.to_str().expect("path is utf-8"),
3681 "HEAD",
3682 ],
3683 );
3684
3685 sweep_orphan_audit_worktrees(&repo);
3686
3687 assert!(
3688 worktree_path.is_dir(),
3689 "sweep must not remove worktree owned by a live PID",
3690 );
3691 assert!(
3692 worktree_is_registered_with_git(&repo, &worktree_path),
3693 "sweep must not unregister worktree owned by a live PID",
3694 );
3695
3696 remove_audit_worktree(&repo, &worktree_path);
3697 let _ = fs::remove_dir_all(&worktree_path);
3698 }
3699
3700 fn make_reusable_path(label: &str) -> PathBuf {
3704 let nanos = std::time::SystemTime::now()
3705 .duration_since(std::time::UNIX_EPOCH)
3706 .expect("clock should be after epoch")
3707 .as_nanos();
3708 std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
3709 }
3710
3711 fn register_reusable_worktree(repo: &Path, path: &Path) {
3715 git(
3716 repo,
3717 &[
3718 "worktree",
3719 "add",
3720 "--detach",
3721 "--quiet",
3722 path.to_str().expect("path is utf-8"),
3723 "HEAD",
3724 ],
3725 );
3726 }
3727
3728 fn write_sidecar_with_age(path: &Path, age: Duration) {
3729 let sidecar = reusable_worktree_last_used_path(path);
3730 let file = std::fs::OpenOptions::new()
3731 .create(true)
3732 .truncate(false)
3733 .write(true)
3734 .open(&sidecar)
3735 .expect("sidecar should open");
3736 let when = SystemTime::now()
3737 .checked_sub(age)
3738 .expect("backdated time should fit in SystemTime");
3739 file.set_modified(when)
3740 .expect("set_modified should succeed");
3741 }
3742
3743 fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
3746 remove_audit_worktree(repo, path);
3747 let _ = fs::remove_dir_all(path);
3748 let _ = fs::remove_file(reusable_worktree_last_used_path(path));
3749 let _ = fs::remove_file(reusable_worktree_lock_path(path));
3750 }
3751
3752 #[test]
3753 fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
3754 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3755 let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
3756 let worktree_path = make_reusable_path("gc-remove");
3757 register_reusable_worktree(&repo, &worktree_path);
3758 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3759
3760 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3761
3762 assert!(
3763 !worktree_path.exists(),
3764 "sweep should remove worktree dir whose sidecar is older than the threshold",
3765 );
3766 assert!(
3767 !worktree_is_registered_with_git(&repo, &worktree_path),
3768 "sweep should unregister the worktree from git",
3769 );
3770 assert!(
3771 !reusable_worktree_last_used_path(&worktree_path).exists(),
3772 "sweep should remove the sidecar `.last-used` file alongside the worktree",
3773 );
3774 cleanup_reusable_worktree(&repo, &worktree_path);
3775 }
3776
3777 #[test]
3778 fn reusable_cache_gc_keeps_fresh_entry() {
3779 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3780 let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
3781 let worktree_path = make_reusable_path("gc-keep");
3782 register_reusable_worktree(&repo, &worktree_path);
3783 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3784
3785 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3786
3787 assert!(
3788 worktree_path.is_dir(),
3789 "sweep must not remove a worktree whose sidecar is fresher than the threshold",
3790 );
3791 assert!(
3792 worktree_is_registered_with_git(&repo, &worktree_path),
3793 "sweep must not unregister a fresh worktree",
3794 );
3795 cleanup_reusable_worktree(&repo, &worktree_path);
3796 }
3797
3798 #[test]
3799 fn reusable_cache_gc_skips_locked_entry() {
3800 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3801 let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
3802 let worktree_path = make_reusable_path("gc-locked");
3803 register_reusable_worktree(&repo, &worktree_path);
3804 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3805
3806 let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
3807 .expect("test should acquire the lock first");
3808
3809 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3810
3811 assert!(
3812 worktree_path.is_dir(),
3813 "sweep must skip a locked entry even when its sidecar is stale",
3814 );
3815 assert!(
3816 worktree_is_registered_with_git(&repo, &worktree_path),
3817 "sweep must not unregister a locked entry",
3818 );
3819 drop(lock);
3820 cleanup_reusable_worktree(&repo, &worktree_path);
3821 }
3822
3823 #[test]
3824 fn reusable_cache_gc_grace_when_sidecar_absent() {
3825 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3826 let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
3827 let worktree_path = make_reusable_path("gc-grace");
3828 register_reusable_worktree(&repo, &worktree_path);
3829 let sidecar = reusable_worktree_last_used_path(&worktree_path);
3830 assert!(
3831 !sidecar.exists(),
3832 "test pre-condition: sidecar should not exist",
3833 );
3834
3835 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3836
3837 assert!(
3838 worktree_path.is_dir(),
3839 "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
3840 );
3841 assert!(
3842 sidecar.exists(),
3843 "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
3844 );
3845 let mtime = std::fs::metadata(&sidecar)
3846 .and_then(|m| m.modified())
3847 .expect("seeded sidecar should have a readable mtime");
3848 let age = SystemTime::now()
3849 .duration_since(mtime)
3850 .unwrap_or(Duration::ZERO);
3851 assert!(
3852 age < Duration::from_mins(1),
3853 "seeded sidecar mtime should be near `now()`, got age {age:?}",
3854 );
3855 cleanup_reusable_worktree(&repo, &worktree_path);
3856 }
3857
3858 #[test]
3859 fn reusable_cache_gc_reclaims_prunable_orphan_when_dir_missing() {
3860 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3861 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
3862 let worktree_path = make_reusable_path("gc-orphan");
3863 register_reusable_worktree(&repo, &worktree_path);
3864 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3867 let sidecar = reusable_worktree_last_used_path(&worktree_path);
3868
3869 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
3872 assert!(
3873 !worktree_path.exists(),
3874 "test pre-condition: cache dir should be gone",
3875 );
3876 assert!(
3877 worktree_admin_entry_present(&repo, &worktree_path),
3878 "test pre-condition: git admin entry should still be registered (prunable)",
3879 );
3880 assert!(
3881 sidecar.exists(),
3882 "test pre-condition: sidecar survives a dir-only reaper",
3883 );
3884
3885 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3886
3887 assert!(
3888 !worktree_admin_entry_present(&repo, &worktree_path),
3889 "sweep should unregister a prunable orphan whose dir was externally removed",
3890 );
3891 assert!(
3892 !sidecar.exists(),
3893 "sweep should remove the stale sidecar for a reclaimed orphan",
3894 );
3895 cleanup_reusable_worktree(&repo, &worktree_path);
3896 }
3897
3898 #[test]
3899 fn reusable_cache_gc_reclaims_prunable_orphan_even_when_age_gc_disabled() {
3900 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3901 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
3902 let worktree_path = make_reusable_path("gc-orphan-nogc");
3903 register_reusable_worktree(&repo, &worktree_path);
3904 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3905 let sidecar = reusable_worktree_last_used_path(&worktree_path);
3906 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
3907 assert!(
3908 worktree_admin_entry_present(&repo, &worktree_path),
3909 "test pre-condition: git admin entry should still be registered (prunable)",
3910 );
3911 assert!(
3912 sidecar.exists(),
3913 "test pre-condition: sidecar survives a dir-only reaper",
3914 );
3915
3916 sweep_old_reusable_caches(&repo, None, true);
3919
3920 assert!(
3921 !worktree_admin_entry_present(&repo, &worktree_path),
3922 "orphan reclaim must run even when age-based GC is disabled",
3923 );
3924 assert!(
3925 !sidecar.exists(),
3926 "sweep should remove the stale sidecar even when age-based GC is disabled",
3927 );
3928 cleanup_reusable_worktree(&repo, &worktree_path);
3929 }
3930
3931 #[test]
3932 fn reusable_cache_gc_preserves_lock_file_after_removal() {
3933 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3934 let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
3935 let worktree_path = make_reusable_path("gc-lockfile");
3936 register_reusable_worktree(&repo, &worktree_path);
3937 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3938 let lock_path = reusable_worktree_lock_path(&worktree_path);
3939 drop(
3940 ReusableWorktreeLock::try_acquire(&worktree_path)
3941 .expect("test should acquire the lock"),
3942 );
3943 assert!(
3944 lock_path.exists(),
3945 "test pre-condition: lock file should exist before sweep",
3946 );
3947
3948 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3949
3950 assert!(
3951 !worktree_path.exists(),
3952 "sweep should still remove the worktree directory",
3953 );
3954 assert!(
3955 lock_path.exists(),
3956 "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
3957 );
3958 let _ = fs::remove_file(&lock_path);
3959 cleanup_reusable_worktree(&repo, &worktree_path);
3960 }
3961
3962 #[test]
3963 fn reuse_or_create_stamps_sidecar_on_fresh_create() {
3964 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3965 let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
3966 let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
3967
3968 let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
3969 .expect("fresh reuse_or_create should succeed on a clean repo");
3970 let cache_path = worktree.path().to_path_buf();
3971 let sidecar = reusable_worktree_last_used_path(&cache_path);
3972
3973 assert!(
3974 sidecar.exists(),
3975 "fresh-create must write the sidecar so age is measured from now",
3976 );
3977 let initial_age = std::fs::metadata(&sidecar)
3978 .and_then(|m| m.modified())
3979 .ok()
3980 .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
3981 .expect("sidecar mtime should be readable and not in the future");
3982 assert!(
3983 initial_age < Duration::from_mins(1),
3984 "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
3985 );
3986
3987 drop(worktree);
3988 cleanup_reusable_worktree(&repo, &cache_path);
3989 }
3990
3991 #[test]
3992 fn days_to_duration_zero_disables() {
3993 assert!(days_to_duration(0).is_none());
3994 assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
3995 assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
3996 }
3997
3998 #[test]
3999 fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
4000 let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
4001 let sidecar = reusable_worktree_last_used_path(&cache_dir);
4002 assert_eq!(sidecar.parent(), cache_dir.parent());
4003 assert_eq!(
4004 sidecar.file_name().and_then(|s| s.to_str()),
4005 Some("fallow-audit-base-cache-abcd-1234.last-used"),
4006 );
4007 }
4008
4009 #[test]
4010 fn touch_last_used_creates_sidecar_if_missing() {
4011 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4012 let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
4013 fs::create_dir(&cache_dir).expect("cache dir should be created");
4014 let sidecar = reusable_worktree_last_used_path(&cache_dir);
4015 assert!(!sidecar.exists(), "sidecar should not exist before touch");
4016
4017 touch_last_used(&cache_dir);
4018
4019 assert!(sidecar.exists(), "touch should create the sidecar");
4020 let mtime = fs::metadata(&sidecar)
4021 .and_then(|m| m.modified())
4022 .expect("sidecar should have an mtime");
4023 let age = SystemTime::now()
4024 .duration_since(mtime)
4025 .unwrap_or(Duration::ZERO);
4026 assert!(
4027 age < Duration::from_mins(1),
4028 "touched sidecar should be near `now()`",
4029 );
4030 }
4031
4032 #[test]
4033 fn reusable_worktree_lock_excludes_concurrent_acquires() {
4034 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4035 let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
4036 let lock_path = reusable_worktree_lock_path(&reusable);
4037
4038 let first = ReusableWorktreeLock::try_acquire(&reusable)
4039 .expect("first acquire on a fresh path should succeed");
4040 assert!(
4041 ReusableWorktreeLock::try_acquire(&reusable).is_none(),
4042 "second acquire must fail while the first is held",
4043 );
4044 drop(first);
4045 assert!(
4046 lock_path.exists(),
4047 "lock file must persist after drop (only the kernel lock is released)",
4048 );
4049 }
4050
4051 #[test]
4052 fn base_analysis_root_preserves_repo_subdirectory_roots() {
4053 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4054 let repo = tmp.path().join("repo");
4055 let app_root = repo.join("apps/mobile");
4056 let base_worktree = tmp.path().join("base-worktree");
4057 fs::create_dir_all(&app_root).expect("app root should be created");
4058 fs::create_dir_all(&base_worktree).expect("base worktree should be created");
4059 git(&repo, &["init", "-b", "main"]);
4060
4061 assert_eq!(
4062 base_analysis_root(&app_root, &base_worktree),
4063 base_worktree.join("apps/mobile")
4064 );
4065 }
4066
4067 #[test]
4068 fn audit_base_worktree_reuses_current_node_modules_context() {
4069 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4070 let root = tmp.path();
4071 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4072 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
4073 .expect("gitignore should be written");
4074 fs::write(
4075 root.join("package.json"),
4076 r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
4077 )
4078 .expect("package.json should be written");
4079 fs::write(
4080 root.join("tsconfig.json"),
4081 r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
4082 )
4083 .expect("tsconfig should be written");
4084 fs::write(
4085 root.join("src/index.ts"),
4086 "import { used } from '@/feature';\nconsole.log(used);\n",
4087 )
4088 .expect("index should be written");
4089 fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
4090 .expect("feature should be written");
4091
4092 git(root, &["init", "-b", "main"]);
4093 git(root, &["add", "."]);
4094 git(
4095 root,
4096 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4097 );
4098
4099 let rn_config = root.join("node_modules/@react-native/typescript-config");
4100 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
4101 fs::write(
4102 rn_config.join("tsconfig.json"),
4103 r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
4104 )
4105 .expect("node_modules tsconfig should be written");
4106
4107 let worktree =
4108 BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
4109 assert!(
4110 worktree.path().join("node_modules").is_dir(),
4111 "base worktree should reuse ignored node_modules from the current checkout"
4112 );
4113 assert!(
4114 worktree
4115 .path()
4116 .join("node_modules/@react-native/typescript-config/tsconfig.json")
4117 .is_file(),
4118 "base worktree should preserve tsconfig extends targets installed in node_modules"
4119 );
4120 }
4121
4122 #[test]
4132 fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
4133 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4134 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4135
4136 let dot_nuxt = host.path().join(".nuxt");
4137 fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
4138 fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
4139 .expect(".nuxt/tsconfig.json should be written");
4140 fs::write(
4141 dot_nuxt.join("tsconfig.app.json"),
4142 r#"{"compilerOptions":{}}"#,
4143 )
4144 .expect(".nuxt/tsconfig.app.json should be written");
4145
4146 materialize_base_dependency_context(host.path(), worktree.path());
4147
4148 let mirrored = worktree.path().join(".nuxt");
4149 assert!(
4150 mirrored.is_dir(),
4151 "base worktree should reuse the ignored .nuxt dir from the host checkout"
4152 );
4153 let link_meta = fs::symlink_metadata(&mirrored)
4154 .expect(".nuxt entry should exist as a symlink in the worktree");
4155 assert!(
4156 link_meta.file_type().is_symlink(),
4157 "base worktree's .nuxt should be a symlink to the host checkout"
4158 );
4159 assert!(
4160 mirrored.join("tsconfig.json").is_file(),
4161 "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
4162 prerequisite check stays quiet"
4163 );
4164 assert!(
4165 mirrored.join("tsconfig.app.json").is_file(),
4166 "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
4167 resolve without falling back to resolver-less resolution"
4168 );
4169 }
4170
4171 #[test]
4176 fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
4177 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4178 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4179
4180 let dot_astro = host.path().join(".astro");
4181 fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
4182 fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
4183 .expect(".astro/types.d.ts should be written");
4184
4185 materialize_base_dependency_context(host.path(), worktree.path());
4186
4187 let mirrored = worktree.path().join(".astro");
4188 assert!(
4189 mirrored.is_dir(),
4190 "base worktree should reuse the ignored .astro dir from the host checkout"
4191 );
4192 assert!(
4193 mirrored.join("types.d.ts").is_file(),
4194 "base worktree should expose generated Astro types so the Astro meta-framework \
4195 prerequisite check stays quiet"
4196 );
4197 }
4198
4199 #[test]
4206 fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
4207 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4208 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4209
4210 materialize_base_dependency_context(host.path(), worktree.path());
4211
4212 assert!(
4213 !worktree.path().join(".nuxt").exists(),
4214 "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
4215 );
4216 assert!(
4217 !worktree.path().join(".astro").exists(),
4218 "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
4219 );
4220 assert!(
4221 !worktree.path().join("node_modules").exists(),
4222 "base worktree should not fabricate a node_modules symlink when the host has none"
4223 );
4224 }
4225
4226 #[test]
4230 fn materialize_base_dependency_context_handles_each_dir_independently() {
4231 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4232 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4233
4234 fs::create_dir_all(host.path().join("node_modules"))
4235 .expect("host node_modules should be created");
4236
4237 materialize_base_dependency_context(host.path(), worktree.path());
4238
4239 assert!(
4240 worktree.path().join("node_modules").is_dir(),
4241 "node_modules should still be symlinked even when host has no .nuxt or .astro"
4242 );
4243 assert!(
4244 !worktree.path().join(".nuxt").exists(),
4245 "missing host .nuxt should leave the worktree slot empty"
4246 );
4247 }
4248
4249 #[test]
4256 fn materialize_base_dependency_context_preserves_real_worktree_dir() {
4257 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4258 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4259
4260 let host_nuxt = host.path().join(".nuxt");
4261 fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
4262 fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
4263 .expect("host .nuxt/tsconfig.json should be written");
4264
4265 let worktree_nuxt = worktree.path().join(".nuxt");
4266 fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
4267 fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
4268 .expect("worktree .nuxt/tsconfig.json should be written");
4269
4270 materialize_base_dependency_context(host.path(), worktree.path());
4271
4272 let link_meta = fs::symlink_metadata(&worktree_nuxt)
4273 .expect(".nuxt entry should still exist in the worktree");
4274 assert!(
4275 !link_meta.file_type().is_symlink(),
4276 "a real base-tracked .nuxt dir must not be replaced by a host symlink"
4277 );
4278 let contents =
4279 fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
4280 assert!(
4281 contents.contains("base"),
4282 "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
4283 );
4284 }
4285
4286 #[test]
4287 fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
4288 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4289 let root = tmp.path();
4290 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
4291 .expect("gitignore should be written");
4292 fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
4293 .expect("package.json should be written");
4294
4295 git(root, &["init", "-b", "main"]);
4296 git(root, &["add", "."]);
4297 git(
4298 root,
4299 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4300 );
4301
4302 let rn_config = root.join("node_modules/@react-native/typescript-config");
4303 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
4304 fs::write(rn_config.join("tsconfig.json"), "{}")
4305 .expect("node_modules tsconfig should be written");
4306
4307 let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
4308 let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
4309 .expect("persistent base worktree should be created");
4310 let worktree_path = first.path().to_path_buf();
4311 assert!(
4312 worktree_path.join("node_modules").is_dir(),
4313 "initial persistent worktree should receive node_modules context"
4314 );
4315 remove_node_modules_context(&worktree_path);
4316 assert!(
4317 !worktree_path.join("node_modules").exists(),
4318 "test setup should remove the dependency context from the reusable worktree"
4319 );
4320 drop(first);
4321
4322 let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
4323 .expect("ready persistent base worktree should be reused");
4324 assert_eq!(reused.path(), worktree_path.as_path());
4325 assert!(
4326 reused.path().join("node_modules").is_dir(),
4327 "ready persistent worktree should refresh missing node_modules context"
4328 );
4329
4330 remove_audit_worktree(root, reused.path());
4331 let _ = fs::remove_dir_all(reused.path());
4332 }
4333
4334 fn remove_node_modules_context(worktree_path: &Path) {
4335 let path = worktree_path.join("node_modules");
4336 let Ok(metadata) = fs::symlink_metadata(&path) else {
4337 return;
4338 };
4339 if metadata.file_type().is_symlink() {
4340 #[cfg(unix)]
4341 let _ = fs::remove_file(path);
4342 #[cfg(windows)]
4343 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
4344 } else {
4345 let _ = fs::remove_dir_all(path);
4346 }
4347 }
4348
4349 #[test]
4350 fn audit_base_snapshot_cache_payload_roundtrips_sets() {
4351 let key = AuditBaseSnapshotCacheKey {
4352 hash: 42,
4353 base_sha: "abc123".to_string(),
4354 };
4355 let snapshot = AuditKeySnapshot {
4356 dead_code: ["dead:a".to_string(), "dead:b".to_string()]
4357 .into_iter()
4358 .collect(),
4359 health: std::iter::once("health:a".to_string()).collect(),
4360 dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
4361 .into_iter()
4362 .collect(),
4363 };
4364
4365 let cached = cached_from_snapshot(&key, &snapshot);
4366 assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
4367 assert_eq!(cached.key_hash, key.hash);
4368 assert_eq!(cached.base_sha, key.base_sha);
4369 assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
4370
4371 let decoded = snapshot_from_cached(cached);
4372 assert_eq!(decoded.dead_code, snapshot.dead_code);
4373 assert_eq!(decoded.health, snapshot.health);
4374 assert_eq!(decoded.dupes, snapshot.dupes);
4375 }
4376
4377 #[test]
4378 fn audit_base_snapshot_cache_key_includes_extended_config() {
4379 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4380 let root = tmp.path();
4381 fs::write(
4382 root.join(".fallowrc.json"),
4383 r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
4384 )
4385 .expect("config should be written");
4386 fs::write(
4387 root.join("base.json"),
4388 r#"{"rules":{"unused-exports":"off"}}"#,
4389 )
4390 .expect("base config should be written");
4391
4392 let config_path = None;
4393 let opts = AuditOptions {
4394 root,
4395 config_path: &config_path,
4396 output: OutputFormat::Json,
4397 no_cache: false,
4398 threads: 1,
4399 quiet: true,
4400 changed_since: Some("HEAD"),
4401 production: false,
4402 production_dead_code: None,
4403 production_health: None,
4404 production_dupes: None,
4405 workspace: None,
4406 changed_workspaces: None,
4407 explain: false,
4408 explain_skipped: false,
4409 performance: false,
4410 group_by: None,
4411 dead_code_baseline: None,
4412 health_baseline: None,
4413 dupes_baseline: None,
4414 max_crap: None,
4415 coverage: None,
4416 coverage_root: None,
4417 gate: AuditGate::NewOnly,
4418 include_entry_exports: false,
4419 runtime_coverage: None,
4420 min_invocations_hot: 100,
4421 };
4422
4423 let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
4424 fs::write(
4425 root.join("base.json"),
4426 r#"{"rules":{"unused-exports":"error"}}"#,
4427 )
4428 .expect("base config should be updated");
4429 let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
4430
4431 assert_ne!(
4432 first["resolved_hash"], second["resolved_hash"],
4433 "extended config changes must invalidate cached base snapshots"
4434 );
4435 }
4436
4437 #[test]
4438 fn audit_gate_all_skips_base_snapshot() {
4439 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4440 let root = tmp.path();
4441 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4442 fs::write(
4443 root.join("package.json"),
4444 r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
4445 )
4446 .expect("package.json should be written");
4447 fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
4448 .expect("index should be written");
4449
4450 git(root, &["init", "-b", "main"]);
4451 git(root, &["add", "."]);
4452 git(
4453 root,
4454 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4455 );
4456 fs::write(
4457 root.join("src/index.ts"),
4458 "export const legacy = 1;\nexport const changed = 2;\n",
4459 )
4460 .expect("changed module should be written");
4461
4462 let config_path = None;
4463 let opts = AuditOptions {
4464 root,
4465 config_path: &config_path,
4466 output: OutputFormat::Json,
4467 no_cache: true,
4468 threads: 1,
4469 quiet: true,
4470 changed_since: Some("HEAD"),
4471 production: false,
4472 production_dead_code: None,
4473 production_health: None,
4474 production_dupes: None,
4475 workspace: None,
4476 changed_workspaces: None,
4477 explain: false,
4478 explain_skipped: false,
4479 performance: false,
4480 group_by: None,
4481 dead_code_baseline: None,
4482 health_baseline: None,
4483 dupes_baseline: None,
4484 max_crap: None,
4485 coverage: None,
4486 coverage_root: None,
4487 gate: AuditGate::All,
4488 include_entry_exports: false,
4489 runtime_coverage: None,
4490 min_invocations_hot: 100,
4491 };
4492
4493 let result = execute_audit(&opts).expect("audit should execute");
4494 assert!(result.base_snapshot.is_none());
4495 assert_eq!(result.attribution.gate, AuditGate::All);
4496 assert_eq!(result.attribution.dead_code_introduced, 0);
4497 assert_eq!(result.attribution.dead_code_inherited, 0);
4498 }
4499
4500 #[test]
4501 fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
4502 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4503 let root = tmp.path();
4504 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4505 fs::write(
4506 root.join("package.json"),
4507 r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
4508 )
4509 .expect("package.json should be written");
4510 fs::write(
4511 root.join(".fallowrc.json"),
4512 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
4513 )
4514 .expect("config should be written");
4515 let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
4516 fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
4517 fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
4518 fs::write(root.join("README.md"), "before\n").expect("readme should be written");
4519
4520 git(root, &["init", "-b", "main"]);
4521 git(root, &["add", "."]);
4522 git(
4523 root,
4524 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4525 );
4526 fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
4527 fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
4528 .expect("cache dir should be created");
4529 fs::write(
4530 root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
4531 b"cache",
4532 )
4533 .expect("cache artifact should be written");
4534
4535 let before_worktrees = audit_worktree_names(root);
4536
4537 let config_path = None;
4538 let opts = AuditOptions {
4539 root,
4540 config_path: &config_path,
4541 output: OutputFormat::Json,
4542 no_cache: true,
4543 threads: 1,
4544 quiet: true,
4545 changed_since: Some("HEAD"),
4546 production: false,
4547 production_dead_code: None,
4548 production_health: None,
4549 production_dupes: None,
4550 workspace: None,
4551 changed_workspaces: None,
4552 explain: false,
4553 explain_skipped: false,
4554 performance: true,
4555 group_by: None,
4556 dead_code_baseline: None,
4557 health_baseline: None,
4558 dupes_baseline: None,
4559 max_crap: None,
4560 coverage: None,
4561 coverage_root: None,
4562 gate: AuditGate::NewOnly,
4563 include_entry_exports: false,
4564 runtime_coverage: None,
4565 min_invocations_hot: 100,
4566 };
4567
4568 let result = execute_audit(&opts).expect("audit should execute");
4569 assert_eq!(result.verdict, AuditVerdict::Pass);
4570 assert_eq!(result.changed_files_count, 2);
4571 assert!(result.base_snapshot_skipped);
4572 assert!(result.base_snapshot.is_some());
4573
4574 let after_worktrees = audit_worktree_names(root);
4575 assert_eq!(
4576 before_worktrees, after_worktrees,
4577 "base snapshot skip must not create a temporary base worktree"
4578 );
4579 }
4580
4581 fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
4582 let mut names: Vec<String> = list_audit_worktrees(repo_root)
4583 .unwrap_or_default()
4584 .into_iter()
4585 .filter_map(|path| {
4586 path.file_name()
4587 .and_then(|name| name.to_str())
4588 .map(str::to_owned)
4589 })
4590 .collect();
4591 names.sort();
4592 names
4593 }
4594
4595 #[test]
4596 fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
4597 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4598 let root = tmp.path();
4599 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4600 fs::write(
4601 root.join("package.json"),
4602 r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
4603 )
4604 .expect("package.json should be written");
4605 fs::write(
4606 root.join("src/index.ts"),
4607 "import { used } from './used';\nused();\n",
4608 )
4609 .expect("index should be written");
4610 fs::write(
4611 root.join("src/used.ts"),
4612 "export function used() {\n return 1;\n}\n",
4613 )
4614 .expect("used module should be written");
4615
4616 git(root, &["init", "-b", "main"]);
4617 git(root, &["add", "."]);
4618 git(
4619 root,
4620 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4621 );
4622 fs::write(
4623 root.join("src/used.ts"),
4624 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
4625 )
4626 .expect("changed module should be written");
4627
4628 let config_path = None;
4629 let opts = AuditOptions {
4630 root,
4631 config_path: &config_path,
4632 output: OutputFormat::Json,
4633 no_cache: true,
4634 threads: 1,
4635 quiet: true,
4636 changed_since: Some("HEAD"),
4637 production: false,
4638 production_dead_code: None,
4639 production_health: None,
4640 production_dupes: None,
4641 workspace: None,
4642 changed_workspaces: None,
4643 explain: false,
4644 explain_skipped: false,
4645 performance: true,
4646 group_by: None,
4647 dead_code_baseline: None,
4648 health_baseline: None,
4649 dupes_baseline: None,
4650 max_crap: None,
4651 coverage: None,
4652 coverage_root: None,
4653 gate: AuditGate::NewOnly,
4654 include_entry_exports: false,
4655 runtime_coverage: None,
4656 min_invocations_hot: 100,
4657 };
4658
4659 let result = execute_audit(&opts).expect("audit should execute");
4660 let health = result.health.expect("health should run for changed files");
4661 let timings = health.timings.expect("performance timings should be kept");
4662 assert!(timings.discover_ms.abs() < f64::EPSILON);
4663 assert!(timings.parse_ms.abs() < f64::EPSILON);
4664 assert!(
4665 result.dupes.is_some(),
4666 "dupes should run when changed files exist"
4667 );
4668 }
4669
4670 #[test]
4671 fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
4672 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4673 let root = tmp.path();
4674 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4675 fs::write(
4676 root.join("package.json"),
4677 r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
4678 )
4679 .expect("package.json should be written");
4680 fs::write(
4681 root.join("src/index.ts"),
4682 "import { used } from './used';\nused();\n",
4683 )
4684 .expect("index should be written");
4685 fs::write(
4686 root.join("src/used.ts"),
4687 "export function used() {\n return 1;\n}\n",
4688 )
4689 .expect("used module should be written");
4690
4691 git(root, &["init", "-b", "main"]);
4692 git(root, &["add", "."]);
4693 git(
4694 root,
4695 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4696 );
4697 fs::write(
4698 root.join("src/used.ts"),
4699 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
4700 )
4701 .expect("changed module should be written");
4702
4703 let config_path = None;
4704 let opts = AuditOptions {
4705 root,
4706 config_path: &config_path,
4707 output: OutputFormat::Json,
4708 no_cache: true,
4709 threads: 1,
4710 quiet: true,
4711 changed_since: Some("HEAD"),
4712 production: false,
4713 production_dead_code: Some(true),
4714 production_health: Some(false),
4715 production_dupes: Some(false),
4716 workspace: None,
4717 changed_workspaces: None,
4718 explain: false,
4719 explain_skipped: false,
4720 performance: true,
4721 group_by: None,
4722 dead_code_baseline: None,
4723 health_baseline: None,
4724 dupes_baseline: None,
4725 max_crap: None,
4726 coverage: None,
4727 coverage_root: None,
4728 gate: AuditGate::NewOnly,
4729 include_entry_exports: false,
4730 runtime_coverage: None,
4731 min_invocations_hot: 100,
4732 };
4733
4734 let result = execute_audit(&opts).expect("audit should execute");
4735 assert!(result.dupes.is_some(), "dupes should still run");
4736 }
4737
4738 #[cfg(unix)]
4739 #[test]
4740 fn remap_focus_files_does_not_canonicalize_through_symlinks() {
4741 let tmp = tempfile::TempDir::new().expect("temp dir");
4742 let real = tmp.path().join("real");
4743 let link = tmp.path().join("link");
4744 fs::create_dir_all(&real).expect("real dir");
4745 std::os::unix::fs::symlink(&real, &link).expect("symlink");
4746 let canonical = link.canonicalize().expect("canonicalize symlink");
4747 assert_ne!(link, canonical, "symlink should not equal its target");
4748
4749 let from_root = PathBuf::from("/repo");
4750 let mut focus = FxHashSet::default();
4751 focus.insert(from_root.join("src/foo.ts"));
4752
4753 let remapped = remap_focus_files(&focus, &from_root, &link)
4754 .expect("remap should succeed for in-prefix files");
4755
4756 let expected = link.join("src/foo.ts");
4757 assert!(
4758 remapped.contains(&expected),
4759 "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
4760 );
4761 }
4762
4763 #[test]
4764 fn remap_focus_files_skips_paths_outside_from_root() {
4765 let from_root = PathBuf::from("/repo/apps/web");
4766 let to_root = PathBuf::from("/wt/apps/web");
4767 let mut focus = FxHashSet::default();
4768 focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
4769 focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
4770
4771 let remapped =
4772 remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
4773
4774 assert_eq!(remapped.len(), 1);
4775 assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
4776 }
4777
4778 #[test]
4779 fn remap_focus_files_returns_none_when_no_paths_map() {
4780 let from_root = PathBuf::from("/repo/apps/web");
4781 let to_root = PathBuf::from("/wt/apps/web");
4782 let mut focus = FxHashSet::default();
4783 focus.insert(PathBuf::from("/elsewhere/foo.ts"));
4784
4785 let remapped = remap_focus_files(&focus, &from_root, &to_root);
4786 assert!(
4787 remapped.is_none(),
4788 "remap should return None when no paths can be mapped, falling caller back to full corpus"
4789 );
4790 }
4791
4792 #[test]
4793 fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
4794 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4795 let root_buf = tmp
4796 .path()
4797 .canonicalize()
4798 .expect("temp root should canonicalize");
4799 let root = root_buf.as_path();
4800 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4801 fs::write(
4802 root.join("package.json"),
4803 r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
4804 )
4805 .expect("package.json should be written");
4806 fs::write(
4807 root.join(".fallowrc.json"),
4808 r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
4809 )
4810 .expect("config should be written");
4811
4812 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";
4813 fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
4814 fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
4815
4816 git(root, &["init", "-b", "main"]);
4817 git(root, &["add", "."]);
4818 git(
4819 root,
4820 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4821 );
4822 fs::write(
4823 root.join("src/changed.ts"),
4824 format!("{dup_block}// touched\n"),
4825 )
4826 .expect("changed file should be modified");
4827 git(root, &["add", "."]);
4828 git(
4829 root,
4830 &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
4831 );
4832
4833 let config_path = None;
4834 let opts = AuditOptions {
4835 root,
4836 config_path: &config_path,
4837 output: OutputFormat::Json,
4838 no_cache: true,
4839 threads: 1,
4840 quiet: true,
4841 changed_since: Some("HEAD~1"),
4842 production: false,
4843 production_dead_code: None,
4844 production_health: None,
4845 production_dupes: None,
4846 workspace: None,
4847 changed_workspaces: None,
4848 explain: false,
4849 explain_skipped: false,
4850 performance: false,
4851 group_by: None,
4852 dead_code_baseline: None,
4853 health_baseline: None,
4854 dupes_baseline: None,
4855 max_crap: None,
4856 coverage: None,
4857 coverage_root: None,
4858 gate: AuditGate::NewOnly,
4859 include_entry_exports: false,
4860 runtime_coverage: None,
4861 min_invocations_hot: 100,
4862 };
4863
4864 let result = execute_audit(&opts).expect("audit should execute");
4865 assert!(
4866 result.base_snapshot_skipped,
4867 "comment-only JS/TS diffs should reuse current keys as the base snapshot"
4868 );
4869 let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
4870 assert!(
4871 !dupes_report.clone_groups.is_empty(),
4872 "current run should detect the pre-existing duplicate"
4873 );
4874 assert_eq!(
4875 result.attribution.duplication_introduced, 0,
4876 "pre-existing duplicate must not be classified as introduced; \
4877 attribution = {:?}",
4878 result.attribution
4879 );
4880 assert!(
4881 result.attribution.duplication_inherited > 0,
4882 "pre-existing duplicate must be classified as inherited; \
4883 attribution = {:?}",
4884 result.attribution
4885 );
4886 }
4887
4888 #[test]
4889 fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
4890 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4891 let root = tmp.path();
4892 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4893 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4894 .expect("node_modules config dir should be created");
4895 fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
4896 fs::write(
4897 root.join("package.json"),
4898 r#"{
4899 "name": "audit-react-native-tsconfig-base",
4900 "private": true,
4901 "main": "src/App.tsx",
4902 "dependencies": {
4903 "react-native": "0.80.0"
4904 }
4905 }"#,
4906 )
4907 .expect("package.json should be written");
4908 fs::write(
4909 root.join("tsconfig.json"),
4910 r#"{
4911 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4912 "compilerOptions": {
4913 "baseUrl": ".",
4914 "paths": {
4915 "@/*": ["src/*"]
4916 }
4917 },
4918 "include": ["src/**/*"]
4919 }"#,
4920 )
4921 .expect("tsconfig should be written");
4922 fs::write(
4923 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4924 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4925 )
4926 .expect("react native tsconfig should be written");
4927 fs::write(
4928 root.join("src/App.tsx"),
4929 r#"import { homeTitle } from "@/screens/Home";
4930
4931export function App() {
4932 return homeTitle;
4933}
4934"#,
4935 )
4936 .expect("app should be written");
4937 fs::write(
4938 root.join("src/screens/Home.ts"),
4939 r#"export const homeTitle = "home";
4940"#,
4941 )
4942 .expect("home should be written");
4943
4944 git(root, &["init", "-b", "main"]);
4945 git(root, &["add", "."]);
4946 git(
4947 root,
4948 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4949 );
4950 fs::write(
4951 root.join("src/App.tsx"),
4952 r#"import { homeTitle } from "@/screens/Home";
4953
4954export function App() {
4955 return homeTitle.toUpperCase();
4956}
4957"#,
4958 )
4959 .expect("app should be modified");
4960
4961 let config_path = None;
4962 let opts = AuditOptions {
4963 root,
4964 config_path: &config_path,
4965 output: OutputFormat::Json,
4966 no_cache: true,
4967 threads: 1,
4968 quiet: true,
4969 changed_since: Some("HEAD"),
4970 production: false,
4971 production_dead_code: None,
4972 production_health: None,
4973 production_dupes: None,
4974 workspace: None,
4975 changed_workspaces: None,
4976 explain: false,
4977 explain_skipped: false,
4978 performance: false,
4979 group_by: None,
4980 dead_code_baseline: None,
4981 health_baseline: None,
4982 dupes_baseline: None,
4983 max_crap: None,
4984 coverage: None,
4985 coverage_root: None,
4986 gate: AuditGate::NewOnly,
4987 include_entry_exports: false,
4988 runtime_coverage: None,
4989 min_invocations_hot: 100,
4990 };
4991
4992 let result = execute_audit(&opts).expect("audit should execute");
4993 assert!(
4994 !result.base_snapshot_skipped,
4995 "source diffs should run a real base snapshot"
4996 );
4997 let base = result
4998 .base_snapshot
4999 .as_ref()
5000 .expect("base snapshot should run");
5001 assert!(
5002 !base
5003 .dead_code
5004 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
5005 "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
5006 base.dead_code
5007 );
5008 assert!(
5009 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
5010 "alias target should stay reachable in the base worktree: {:?}",
5011 base.dead_code
5012 );
5013 let check = result.check.as_ref().expect("dead-code audit should run");
5014 assert!(
5015 check.results.unresolved_imports.is_empty(),
5016 "HEAD audit should also resolve @/* aliases: {:?}",
5017 check.results.unresolved_imports
5018 );
5019 }
5020
5021 #[test]
5022 fn audit_base_preserves_subdirectory_root_resolution() {
5023 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5024 let repo = tmp.path().join("repo");
5025 let root = repo.join("apps/mobile");
5026 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
5027 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
5028 .expect("node_modules config dir should be created");
5029 fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
5030 .expect("gitignore should be written");
5031 fs::write(
5032 root.join("package.json"),
5033 r#"{
5034 "name": "audit-subdir-react-native-tsconfig-base",
5035 "private": true,
5036 "main": "src/App.tsx",
5037 "dependencies": {
5038 "react-native": "0.80.0"
5039 }
5040 }"#,
5041 )
5042 .expect("package.json should be written");
5043 fs::write(
5044 root.join("tsconfig.json"),
5045 r#"{
5046 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
5047 "compilerOptions": {
5048 "baseUrl": ".",
5049 "paths": {
5050 "@/*": ["src/*"]
5051 }
5052 },
5053 "include": ["src/**/*"]
5054 }"#,
5055 )
5056 .expect("tsconfig should be written");
5057 fs::write(
5058 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
5059 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
5060 )
5061 .expect("react native tsconfig should be written");
5062 fs::write(
5063 root.join("src/App.tsx"),
5064 r#"import { homeTitle } from "@/screens/Home";
5065
5066export function App() {
5067 return homeTitle;
5068}
5069"#,
5070 )
5071 .expect("app should be written");
5072 fs::write(
5073 root.join("src/screens/Home.ts"),
5074 r#"export const homeTitle = "home";
5075"#,
5076 )
5077 .expect("home should be written");
5078
5079 git(&repo, &["init", "-b", "main"]);
5080 git(&repo, &["add", "."]);
5081 git(
5082 &repo,
5083 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5084 );
5085 fs::write(
5086 root.join("src/App.tsx"),
5087 r#"import { homeTitle } from "@/screens/Home";
5088
5089export function App() {
5090 return homeTitle.toUpperCase();
5091}
5092"#,
5093 )
5094 .expect("app should be modified");
5095
5096 let config_path = None;
5097 let opts = AuditOptions {
5098 root: &root,
5099 config_path: &config_path,
5100 output: OutputFormat::Json,
5101 no_cache: true,
5102 threads: 1,
5103 quiet: true,
5104 changed_since: Some("HEAD"),
5105 production: false,
5106 production_dead_code: None,
5107 production_health: None,
5108 production_dupes: None,
5109 workspace: None,
5110 changed_workspaces: None,
5111 explain: false,
5112 explain_skipped: false,
5113 performance: false,
5114 group_by: None,
5115 dead_code_baseline: None,
5116 health_baseline: None,
5117 dupes_baseline: None,
5118 max_crap: None,
5119 coverage: None,
5120 coverage_root: None,
5121 gate: AuditGate::NewOnly,
5122 include_entry_exports: false,
5123 runtime_coverage: None,
5124 min_invocations_hot: 100,
5125 };
5126
5127 let result = execute_audit(&opts).expect("audit should execute");
5128 assert!(
5129 !result.base_snapshot_skipped,
5130 "source diffs should run a real base snapshot"
5131 );
5132 let base = result
5133 .base_snapshot
5134 .as_ref()
5135 .expect("base snapshot should run");
5136 assert!(
5137 !base
5138 .dead_code
5139 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
5140 "base audit should analyze from the app subdirectory, not the repo root: {:?}",
5141 base.dead_code
5142 );
5143 assert!(
5144 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
5145 "subdirectory base audit should keep alias targets reachable: {:?}",
5146 base.dead_code
5147 );
5148 }
5149
5150 #[test]
5151 fn audit_base_uses_new_explicit_config_without_hard_failure() {
5152 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5153 let root = tmp.path();
5154 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5155 fs::write(
5156 root.join("package.json"),
5157 r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
5158 )
5159 .expect("package.json should be written");
5160 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5161 .expect("index should be written");
5162
5163 git(root, &["init", "-b", "main"]);
5164 git(root, &["add", "."]);
5165 git(
5166 root,
5167 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5168 );
5169
5170 let explicit_config = root.join(".fallowrc.json");
5171 fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
5172 .expect("new config should be written");
5173 fs::write(root.join("src/index.ts"), "export const used = 2;\n")
5174 .expect("index should be modified");
5175
5176 let config_path = Some(explicit_config);
5177 let opts = AuditOptions {
5178 root,
5179 config_path: &config_path,
5180 output: OutputFormat::Json,
5181 no_cache: true,
5182 threads: 1,
5183 quiet: true,
5184 changed_since: Some("HEAD"),
5185 production: false,
5186 production_dead_code: None,
5187 production_health: None,
5188 production_dupes: None,
5189 workspace: None,
5190 changed_workspaces: None,
5191 explain: false,
5192 explain_skipped: false,
5193 performance: false,
5194 group_by: None,
5195 dead_code_baseline: None,
5196 health_baseline: None,
5197 dupes_baseline: None,
5198 max_crap: None,
5199 coverage: None,
5200 coverage_root: None,
5201 gate: AuditGate::NewOnly,
5202 include_entry_exports: false,
5203 runtime_coverage: None,
5204 min_invocations_hot: 100,
5205 };
5206
5207 let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
5208 assert!(
5209 result.base_snapshot.is_some(),
5210 "base snapshot should use the current explicit config even when the base commit lacks it"
5211 );
5212 }
5213
5214 #[test]
5215 fn audit_base_uses_current_discovered_config_for_attribution() {
5216 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5217 let root = tmp.path();
5218 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5219 fs::write(
5220 root.join("package.json"),
5221 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5222 )
5223 .expect("package.json should be written");
5224 fs::write(
5225 root.join(".fallowrc.json"),
5226 r#"{"rules":{"unused-dependencies":"off"}}"#,
5227 )
5228 .expect("base config should be written");
5229 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5230 .expect("index should be written");
5231
5232 git(root, &["init", "-b", "main"]);
5233 git(root, &["add", "."]);
5234 git(
5235 root,
5236 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5237 );
5238
5239 fs::write(
5240 root.join(".fallowrc.json"),
5241 r#"{"rules":{"unused-dependencies":"error"}}"#,
5242 )
5243 .expect("current config should be written");
5244 fs::write(
5245 root.join("package.json"),
5246 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5247 )
5248 .expect("package.json should be touched");
5249
5250 let config_path = None;
5251 let opts = AuditOptions {
5252 root,
5253 config_path: &config_path,
5254 output: OutputFormat::Json,
5255 no_cache: true,
5256 threads: 1,
5257 quiet: true,
5258 changed_since: Some("HEAD"),
5259 production: false,
5260 production_dead_code: None,
5261 production_health: None,
5262 production_dupes: None,
5263 workspace: None,
5264 changed_workspaces: None,
5265 explain: false,
5266 explain_skipped: false,
5267 performance: false,
5268 group_by: None,
5269 dead_code_baseline: None,
5270 health_baseline: None,
5271 dupes_baseline: None,
5272 max_crap: None,
5273 coverage: None,
5274 coverage_root: None,
5275 gate: AuditGate::NewOnly,
5276 include_entry_exports: false,
5277 runtime_coverage: None,
5278 min_invocations_hot: 100,
5279 };
5280
5281 let result = execute_audit(&opts).expect("audit should execute");
5282 assert_eq!(
5283 result.attribution.dead_code_introduced, 0,
5284 "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
5285 result.attribution
5286 );
5287 assert!(
5288 result.attribution.dead_code_inherited > 0,
5289 "pre-existing changed-file findings should be classified as inherited: {:?}",
5290 result.attribution
5291 );
5292 }
5293
5294 #[test]
5295 fn audit_base_current_config_attribution_survives_cache_hit() {
5296 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5297 let root = tmp.path();
5298 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5299 fs::write(
5300 root.join("package.json"),
5301 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5302 )
5303 .expect("package.json should be written");
5304 fs::write(
5305 root.join(".fallowrc.json"),
5306 r#"{"rules":{"unused-dependencies":"off"}}"#,
5307 )
5308 .expect("base config should be written");
5309 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5310 .expect("index should be written");
5311
5312 git(root, &["init", "-b", "main"]);
5313 git(root, &["add", "."]);
5314 git(
5315 root,
5316 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5317 );
5318
5319 fs::write(
5320 root.join(".fallowrc.json"),
5321 r#"{"rules":{"unused-dependencies":"error"}}"#,
5322 )
5323 .expect("current config should be written");
5324 fs::write(
5325 root.join("package.json"),
5326 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5327 )
5328 .expect("package.json should be touched");
5329
5330 let config_path = None;
5331 let opts = AuditOptions {
5332 root,
5333 config_path: &config_path,
5334 output: OutputFormat::Json,
5335 no_cache: false,
5336 threads: 1,
5337 quiet: true,
5338 changed_since: Some("HEAD"),
5339 production: false,
5340 production_dead_code: None,
5341 production_health: None,
5342 production_dupes: None,
5343 workspace: None,
5344 changed_workspaces: None,
5345 explain: false,
5346 explain_skipped: false,
5347 performance: false,
5348 group_by: None,
5349 dead_code_baseline: None,
5350 health_baseline: None,
5351 dupes_baseline: None,
5352 max_crap: None,
5353 coverage: None,
5354 coverage_root: None,
5355 gate: AuditGate::NewOnly,
5356 include_entry_exports: false,
5357 runtime_coverage: None,
5358 min_invocations_hot: 100,
5359 };
5360
5361 let first = execute_audit(&opts).expect("first audit should execute");
5362 assert_eq!(
5363 first.attribution.dead_code_introduced, 0,
5364 "first audit should classify pre-existing findings as inherited: {:?}",
5365 first.attribution
5366 );
5367
5368 let changed_files =
5369 crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
5370 let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
5371 .expect("cache key should compute")
5372 .expect("cache key should exist");
5373 assert!(
5374 load_cached_base_snapshot(&opts, &key).is_some(),
5375 "first audit should store a reusable base snapshot"
5376 );
5377
5378 let second = execute_audit(&opts).expect("second audit should execute");
5379 assert_eq!(
5380 second.attribution.dead_code_introduced, 0,
5381 "cache hit should keep current-config attribution stable: {:?}",
5382 second.attribution
5383 );
5384 assert!(
5385 second.attribution.dead_code_inherited > 0,
5386 "cache hit should preserve inherited base findings: {:?}",
5387 second.attribution
5388 );
5389 }
5390
5391 #[test]
5392 fn audit_dupes_only_materializes_groups_touching_changed_files() {
5393 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5394 let root_path = tmp
5395 .path()
5396 .canonicalize()
5397 .expect("temp root should canonicalize");
5398 let root = root_path.as_path();
5399 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5400 fs::write(
5401 root.join("package.json"),
5402 r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
5403 )
5404 .expect("package.json should be written");
5405 fs::write(
5406 root.join(".fallowrc.json"),
5407 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
5408 )
5409 .expect("config should be written");
5410
5411 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";
5412 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";
5413 fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
5414 fs::write(root.join("src/focused-copy.ts"), focused_code)
5415 .expect("focused copy should be written");
5416 fs::write(root.join("src/untouched-a.ts"), untouched_code)
5417 .expect("untouched a should be written");
5418 fs::write(root.join("src/untouched-b.ts"), untouched_code)
5419 .expect("untouched b should be written");
5420
5421 git(root, &["init", "-b", "main"]);
5422 git(root, &["add", "."]);
5423 git(
5424 root,
5425 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5426 );
5427 fs::write(
5428 root.join("src/changed.ts"),
5429 format!("{focused_code}export const changedMarker = true;\n"),
5430 )
5431 .expect("changed file should be modified");
5432
5433 let config_path = None;
5434 let opts = AuditOptions {
5435 root,
5436 config_path: &config_path,
5437 output: OutputFormat::Json,
5438 no_cache: true,
5439 threads: 1,
5440 quiet: true,
5441 changed_since: Some("HEAD"),
5442 production: false,
5443 production_dead_code: None,
5444 production_health: None,
5445 production_dupes: None,
5446 workspace: None,
5447 changed_workspaces: None,
5448 explain: false,
5449 explain_skipped: false,
5450 performance: false,
5451 group_by: None,
5452 dead_code_baseline: None,
5453 health_baseline: None,
5454 dupes_baseline: None,
5455 max_crap: None,
5456 coverage: None,
5457 coverage_root: None,
5458 gate: AuditGate::All,
5459 include_entry_exports: false,
5460 runtime_coverage: None,
5461 min_invocations_hot: 100,
5462 };
5463
5464 let result = execute_audit(&opts).expect("audit should execute");
5465 let dupes = result.dupes.expect("dupes should run");
5466 let changed_path = root.join("src/changed.ts");
5467
5468 assert!(
5469 !dupes.report.clone_groups.is_empty(),
5470 "changed file should still match unchanged duplicate code"
5471 );
5472 assert!(dupes.report.clone_groups.iter().all(|group| {
5473 group
5474 .instances
5475 .iter()
5476 .any(|instance| instance.file == changed_path)
5477 }));
5478 }
5479}