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: Duration, quiet: bool) {
1211 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1212 return;
1213 };
1214 let now = SystemTime::now();
1215 let mut removed: u32 = 0;
1216 for path in worktrees {
1217 if !is_reusable_audit_worktree_path(&path) {
1218 continue;
1219 }
1220 let sidecar = reusable_worktree_last_used_path(&path);
1221 let sidecar_mtime = std::fs::metadata(&sidecar)
1222 .ok()
1223 .and_then(|m| m.modified().ok());
1224 let Some(mtime) = sidecar_mtime else {
1225 touch_last_used(&path);
1226 continue;
1227 };
1228 let Ok(age) = now.duration_since(mtime) else {
1229 continue;
1230 };
1231 if age < max_age {
1232 continue;
1233 }
1234 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
1235 continue;
1236 };
1237 remove_audit_worktree(repo_root, &path);
1238 let dir_removed = match std::fs::remove_dir_all(&path) {
1239 Ok(()) => true,
1240 Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
1241 Err(err) => {
1242 tracing::warn!(
1243 path = %path.display(),
1244 error = %err,
1245 "failed to remove stale reusable audit worktree directory; entry may leak",
1246 );
1247 false
1248 }
1249 };
1250 let _ = std::fs::remove_file(&sidecar);
1251 if dir_removed {
1252 removed += 1;
1253 }
1254 }
1255 if removed == 0 {
1256 return;
1257 }
1258 let mut command = Command::new("git");
1259 command
1260 .args(["worktree", "prune", "--expire=now"])
1261 .current_dir(repo_root);
1262 clear_ambient_git_env(&mut command);
1263 let _ = command.output();
1264 tracing::info!(
1265 count = removed,
1266 "reclaimed stale audit base-snapshot caches",
1267 );
1268 if !quiet {
1269 let s = plural(removed as usize);
1270 let _ = writeln!(
1271 std::io::stderr(),
1272 "fallow: reclaimed {removed} stale base-snapshot cache{s}",
1273 );
1274 }
1275}
1276
1277fn reusable_audit_worktree_path(repo_root: &Path, base_sha: &str) -> PathBuf {
1278 let repo_root = git_toplevel(repo_root).unwrap_or_else(|| repo_root.to_path_buf());
1279 let repo_root = dunce::canonicalize(&repo_root).unwrap_or(repo_root);
1280 let repo_hash = xxh3_64(repo_root.to_string_lossy().as_bytes());
1281 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1282 std::env::temp_dir().join(format!(
1283 "fallow-audit-base-cache-{repo_hash:016x}-{sha_prefix}"
1284 ))
1285}
1286
1287fn reusable_audit_worktree_is_ready(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1288 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1289 return false;
1290 }
1291 git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha)
1292}
1293
1294fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1295 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1296 return false;
1297 };
1298 worktrees.iter().any(|worktree| paths_equal(worktree, path))
1299}
1300
1301fn paths_equal(left: &Path, right: &Path) -> bool {
1302 if left == right {
1303 return true;
1304 }
1305 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
1306 (Ok(left), Ok(right)) => left == right,
1307 _ => false,
1308 }
1309}
1310
1311const MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
1328
1329fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
1330 for &name in MATERIALIZED_CONTEXT_DIRS {
1331 let source = repo_root.join(name);
1332 if !source.is_dir() {
1333 continue;
1334 }
1335
1336 let destination = worktree_path.join(name);
1337 if destination.is_dir() {
1338 continue;
1339 }
1340 if let Ok(metadata) = std::fs::symlink_metadata(&destination) {
1341 if !metadata.file_type().is_symlink() {
1342 continue;
1343 }
1344 let _ = std::fs::remove_file(&destination);
1345 }
1346
1347 let _ = symlink_dependency_dir(&source, &destination);
1348 }
1349}
1350
1351#[cfg(unix)]
1352fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1353 std::os::unix::fs::symlink(source, destination)
1354}
1355
1356#[cfg(windows)]
1357fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1358 std::os::windows::fs::symlink_dir(source, destination)
1359}
1360
1361fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1362 let mut command = Command::new("git");
1363 command
1364 .args([
1365 "worktree",
1366 "remove",
1367 "--force",
1368 path.to_string_lossy().as_ref(),
1369 ])
1370 .current_dir(repo_root);
1371 clear_ambient_git_env(&mut command);
1372 match crate::signal::scoped_child::output(&mut command) {
1373 Ok(output) => {
1374 if !output.status.success() && path.exists() {
1375 let stderr = String::from_utf8_lossy(&output.stderr);
1376 tracing::warn!(
1377 path = %path.display(),
1378 stderr = %stderr.trim(),
1379 "git worktree remove failed; the directory remains and may leak",
1380 );
1381 }
1382 }
1383 Err(err) => {
1384 tracing::warn!(
1385 path = %path.display(),
1386 error = %err,
1387 "git worktree remove subprocess failed to spawn",
1388 );
1389 }
1390 }
1391}
1392
1393fn sweep_orphan_audit_worktrees(repo_root: &Path) {
1394 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1395 return;
1396 };
1397 let mut removed_any = false;
1398 for path in worktrees {
1399 if !is_fallow_audit_worktree_path(&path)
1400 || is_reusable_audit_worktree_path(&path)
1401 || audit_worktree_process_is_alive(&path)
1402 {
1403 continue;
1404 }
1405 remove_audit_worktree(repo_root, &path);
1406 let _ = std::fs::remove_dir_all(&path);
1407 removed_any = true;
1408 }
1409 if removed_any {
1410 let mut command = Command::new("git");
1411 command
1412 .args(["worktree", "prune", "--expire=now"])
1413 .current_dir(repo_root);
1414 clear_ambient_git_env(&mut command);
1415 let _ = command.output();
1416 }
1417}
1418
1419fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
1420 let mut command = Command::new("git");
1421 command
1422 .args(["worktree", "list", "--porcelain"])
1423 .current_dir(repo_root);
1424 clear_ambient_git_env(&mut command);
1425 let output = command.output().ok()?;
1426 if !output.status.success() {
1427 return None;
1428 }
1429 Some(parse_worktree_list(&String::from_utf8_lossy(
1430 &output.stdout,
1431 )))
1432}
1433
1434fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
1435 output
1436 .lines()
1437 .filter_map(|line| line.strip_prefix("worktree "))
1438 .map(PathBuf::from)
1439 .filter(|path| is_fallow_audit_worktree_path(path))
1440 .collect()
1441}
1442
1443fn is_fallow_audit_worktree_path(path: &Path) -> bool {
1444 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1445 return false;
1446 };
1447 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
1448}
1449
1450fn is_reusable_audit_worktree_path(path: &Path) -> bool {
1451 path.file_name()
1452 .and_then(|name| name.to_str())
1453 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
1454}
1455
1456fn path_is_inside_temp_dir(path: &Path) -> bool {
1457 let temp = std::env::temp_dir();
1458 let simple_path = dunce::simplified(path);
1459 let simple_temp = dunce::simplified(&temp);
1460 if simple_path.starts_with(simple_temp) {
1461 return true;
1462 }
1463 let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
1464 return false;
1465 };
1466 let simple_canonical_temp = dunce::simplified(&canonical_temp);
1467 simple_path.starts_with(simple_canonical_temp)
1468 || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
1469 dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
1470 })
1471}
1472
1473fn audit_worktree_process_is_alive(path: &Path) -> bool {
1474 let Some(pid) = path
1475 .file_name()
1476 .and_then(|name| name.to_str())
1477 .and_then(audit_worktree_pid)
1478 else {
1479 return false;
1480 };
1481 process_is_alive(pid)
1482}
1483
1484fn audit_worktree_pid(name: &str) -> Option<u32> {
1485 name.strip_prefix("fallow-audit-base-")?
1486 .split('-')
1487 .next()?
1488 .parse()
1489 .ok()
1490}
1491
1492#[cfg(unix)]
1493pub fn process_is_alive(pid: u32) -> bool {
1494 Command::new("kill")
1495 .args(["-0", &pid.to_string()])
1496 .output()
1497 .is_ok_and(|output| output.status.success())
1498}
1499
1500#[cfg(windows)]
1501pub fn process_is_alive(pid: u32) -> bool {
1502 windows_process::is_alive(pid)
1503}
1504
1505#[cfg(not(any(unix, windows)))]
1506pub fn process_is_alive(_pid: u32) -> bool {
1507 true
1508}
1509
1510#[cfg(windows)]
1511#[allow(
1512 unsafe_code,
1513 reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
1514)]
1515mod windows_process {
1516 use windows_sys::Win32::Foundation::{
1517 CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
1518 WAIT_OBJECT_0,
1519 };
1520 use windows_sys::Win32::System::Threading::{
1521 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
1522 };
1523
1524 struct ProcessHandle(HANDLE);
1528
1529 impl Drop for ProcessHandle {
1530 fn drop(&mut self) {
1531 unsafe {
1535 CloseHandle(self.0);
1536 }
1537 }
1538 }
1539
1540 pub(super) fn is_alive(pid: u32) -> bool {
1548 let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1552 if raw.is_null() {
1553 let err = unsafe { GetLastError() };
1556 #[expect(
1557 clippy::match_same_arms,
1558 reason = "named arm documents the cross-session case"
1559 )]
1560 return match err {
1561 ERROR_INVALID_PARAMETER => false,
1562 ERROR_ACCESS_DENIED => true,
1563 _ => true,
1564 };
1565 }
1566 let handle = ProcessHandle(raw);
1567 let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
1570 wait_result != WAIT_OBJECT_0
1571 }
1572}
1573
1574impl Drop for BaseWorktree {
1575 fn drop(&mut self) {
1576 if self.persistent {
1577 return;
1578 }
1579 remove_audit_worktree(&self.repo_root, &self.path);
1580 let _ = std::fs::remove_dir_all(&self.path);
1581 }
1582}
1583
1584fn relative_key_path(path: &Path, root: &Path) -> String {
1585 let simple_path = dunce::simplified(path);
1586 let simple_root = dunce::simplified(root);
1587 simple_path
1588 .strip_prefix(simple_root)
1589 .unwrap_or(simple_path)
1590 .to_string_lossy()
1591 .replace('\\', "/")
1592}
1593
1594fn dependency_location_key(location: &fallow_core::results::DependencyLocation) -> &'static str {
1595 match location {
1596 fallow_core::results::DependencyLocation::Dependencies => "unused-dependency",
1597 fallow_core::results::DependencyLocation::DevDependencies => "unused-dev-dependency",
1598 fallow_core::results::DependencyLocation::OptionalDependencies => {
1599 "unused-optional-dependency"
1600 }
1601 }
1602}
1603
1604fn unused_dependency_key(item: &fallow_core::results::UnusedDependency, root: &Path) -> String {
1605 format!(
1606 "{}:{}:{}",
1607 dependency_location_key(&item.location),
1608 relative_key_path(&item.path, root),
1609 item.package_name
1610 )
1611}
1612
1613fn unlisted_dependency_key(item: &fallow_core::results::UnlistedDependency, root: &Path) -> String {
1614 let mut sites = item
1615 .imported_from
1616 .iter()
1617 .map(|site| {
1618 format!(
1619 "{}:{}:{}",
1620 relative_key_path(&site.path, root),
1621 site.line,
1622 site.col
1623 )
1624 })
1625 .collect::<Vec<_>>();
1626 sites.sort();
1627 sites.dedup();
1628 format!(
1629 "unlisted-dependency:{}:{}",
1630 item.package_name,
1631 sites.join("|")
1632 )
1633}
1634
1635fn unused_member_key(
1636 rule_id: &str,
1637 item: &fallow_core::results::UnusedMember,
1638 root: &Path,
1639) -> String {
1640 format!(
1641 "{}:{}:{}:{}",
1642 rule_id,
1643 relative_key_path(&item.path, root),
1644 item.parent_name,
1645 item.member_name
1646 )
1647}
1648
1649fn unused_catalog_entry_key(
1650 item: &fallow_core::results::UnusedCatalogEntry,
1651 root: &Path,
1652) -> String {
1653 format!(
1654 "unused-catalog-entry:{}:{}:{}:{}",
1655 relative_key_path(&item.path, root),
1656 item.line,
1657 item.catalog_name,
1658 item.entry_name
1659 )
1660}
1661
1662fn empty_catalog_group_key(item: &fallow_core::results::EmptyCatalogGroup, root: &Path) -> String {
1663 format!(
1664 "empty-catalog-group:{}:{}:{}",
1665 relative_key_path(&item.path, root),
1666 item.line,
1667 item.catalog_name
1668 )
1669}
1670
1671#[expect(
1672 clippy::too_many_lines,
1673 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"
1674)]
1675fn dead_code_keys(
1676 results: &fallow_core::results::AnalysisResults,
1677 root: &Path,
1678) -> FxHashSet<String> {
1679 let mut keys = FxHashSet::default();
1680 for item in &results.unused_files {
1681 keys.insert(format!(
1682 "unused-file:{}",
1683 relative_key_path(&item.file.path, root)
1684 ));
1685 }
1686 for item in &results.unused_exports {
1687 keys.insert(format!(
1688 "unused-export:{}:{}",
1689 relative_key_path(&item.export.path, root),
1690 item.export.export_name
1691 ));
1692 }
1693 for item in &results.unused_types {
1694 keys.insert(format!(
1695 "unused-type:{}:{}",
1696 relative_key_path(&item.export.path, root),
1697 item.export.export_name
1698 ));
1699 }
1700 for item in &results.private_type_leaks {
1701 keys.insert(format!(
1702 "private-type-leak:{}:{}:{}",
1703 relative_key_path(&item.leak.path, root),
1704 item.leak.export_name,
1705 item.leak.type_name
1706 ));
1707 }
1708 for item in results
1709 .unused_dependencies
1710 .iter()
1711 .map(|f| &f.dep)
1712 .chain(results.unused_dev_dependencies.iter().map(|f| &f.dep))
1713 .chain(results.unused_optional_dependencies.iter().map(|f| &f.dep))
1714 {
1715 keys.insert(unused_dependency_key(item, root));
1716 }
1717 for item in &results.unused_enum_members {
1718 keys.insert(unused_member_key("unused-enum-member", &item.member, root));
1719 }
1720 for item in &results.unused_class_members {
1721 keys.insert(unused_member_key("unused-class-member", &item.member, root));
1722 }
1723 for item in &results.unresolved_imports {
1724 keys.insert(format!(
1725 "unresolved-import:{}:{}",
1726 relative_key_path(&item.import.path, root),
1727 item.import.specifier
1728 ));
1729 }
1730 for item in results.unlisted_dependencies.iter().map(|f| &f.dep) {
1731 keys.insert(unlisted_dependency_key(item, root));
1732 }
1733 for item in &results.duplicate_exports {
1734 let mut locations: Vec<String> = item
1735 .export
1736 .locations
1737 .iter()
1738 .map(|loc| relative_key_path(&loc.path, root))
1739 .collect();
1740 locations.sort();
1741 locations.dedup();
1742 keys.insert(format!(
1743 "duplicate-export:{}:{}",
1744 item.export.export_name,
1745 locations.join("|")
1746 ));
1747 }
1748 for item in &results.type_only_dependencies {
1749 keys.insert(format!(
1750 "type-only-dependency:{}:{}",
1751 relative_key_path(&item.dep.path, root),
1752 item.dep.package_name
1753 ));
1754 }
1755 for item in &results.test_only_dependencies {
1756 keys.insert(format!(
1757 "test-only-dependency:{}:{}",
1758 relative_key_path(&item.dep.path, root),
1759 item.dep.package_name
1760 ));
1761 }
1762 for item in &results.circular_dependencies {
1763 let mut files: Vec<String> = item
1764 .cycle
1765 .files
1766 .iter()
1767 .map(|path| relative_key_path(path, root))
1768 .collect();
1769 files.sort();
1770 keys.insert(format!("circular-dependency:{}", files.join("|")));
1771 }
1772 for item in &results.re_export_cycles {
1773 let kind = match item.cycle.kind {
1774 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
1775 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
1776 };
1777 let mut files: Vec<String> = item
1778 .cycle
1779 .files
1780 .iter()
1781 .map(|path| relative_key_path(path, root))
1782 .collect();
1783 files.sort();
1784 keys.insert(format!("re-export-cycle:{kind}:{}", files.join("|")));
1785 }
1786 for item in &results.boundary_violations {
1787 keys.insert(format!(
1788 "boundary-violation:{}:{}:{}",
1789 relative_key_path(&item.violation.from_path, root),
1790 relative_key_path(&item.violation.to_path, root),
1791 item.violation.import_specifier
1792 ));
1793 }
1794 for item in &results.stale_suppressions {
1795 keys.insert(format!(
1796 "stale-suppression:{}:{}",
1797 relative_key_path(&item.path, root),
1798 item.description()
1799 ));
1800 }
1801 for item in &results.unresolved_catalog_references {
1802 keys.insert(format!(
1803 "unresolved-catalog-reference:{}:{}:{}:{}",
1804 relative_key_path(&item.reference.path, root),
1805 item.reference.line,
1806 item.reference.catalog_name,
1807 item.reference.entry_name
1808 ));
1809 }
1810 for item in &results.unused_catalog_entries {
1811 keys.insert(unused_catalog_entry_key(&item.entry, root));
1812 }
1813 for item in &results.empty_catalog_groups {
1814 keys.insert(empty_catalog_group_key(&item.group, root));
1815 }
1816 for item in &results.unused_dependency_overrides {
1817 keys.insert(format!(
1818 "unused-dependency-override:{}:{}:{}",
1819 relative_key_path(&item.entry.path, root),
1820 item.entry.line,
1821 item.entry.raw_key
1822 ));
1823 }
1824 for item in &results.misconfigured_dependency_overrides {
1825 keys.insert(format!(
1826 "misconfigured-dependency-override:{}:{}:{}",
1827 relative_key_path(&item.entry.path, root),
1828 item.entry.line,
1829 item.entry.raw_key
1830 ));
1831 }
1832 keys
1833}
1834
1835#[expect(
1836 clippy::too_many_lines,
1837 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"
1838)]
1839fn retain_introduced_dead_code(
1840 results: &mut fallow_core::results::AnalysisResults,
1841 root: &Path,
1842 base: Option<&FxHashSet<String>>,
1843) {
1844 let Some(base) = base else {
1845 return;
1846 };
1847 results.unused_files.retain(|item| {
1848 !base.contains(&format!(
1849 "unused-file:{}",
1850 relative_key_path(&item.file.path, root)
1851 ))
1852 });
1853 results.unused_exports.retain(|item| {
1854 !base.contains(&format!(
1855 "unused-export:{}:{}",
1856 relative_key_path(&item.export.path, root),
1857 item.export.export_name
1858 ))
1859 });
1860 results.unused_types.retain(|item| {
1861 !base.contains(&format!(
1862 "unused-type:{}:{}",
1863 relative_key_path(&item.export.path, root),
1864 item.export.export_name
1865 ))
1866 });
1867 let introduced = dead_code_keys(results, root)
1868 .into_iter()
1869 .filter(|key| !base.contains(key))
1870 .collect::<FxHashSet<_>>();
1871 let keep = |key: String| introduced.contains(&key);
1872 results.private_type_leaks.retain(|item| {
1873 keep(format!(
1874 "private-type-leak:{}:{}:{}",
1875 relative_key_path(&item.leak.path, root),
1876 item.leak.export_name,
1877 item.leak.type_name
1878 ))
1879 });
1880 results
1881 .unused_dependencies
1882 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1883 results
1884 .unused_dev_dependencies
1885 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1886 results
1887 .unused_optional_dependencies
1888 .retain(|item| keep(unused_dependency_key(&item.dep, root)));
1889 results
1890 .unused_enum_members
1891 .retain(|item| keep(unused_member_key("unused-enum-member", &item.member, root)));
1892 results
1893 .unused_class_members
1894 .retain(|item| keep(unused_member_key("unused-class-member", &item.member, root)));
1895 results.unresolved_imports.retain(|item| {
1896 keep(format!(
1897 "unresolved-import:{}:{}",
1898 relative_key_path(&item.import.path, root),
1899 item.import.specifier
1900 ))
1901 });
1902 results
1903 .unlisted_dependencies
1904 .retain(|item| keep(unlisted_dependency_key(&item.dep, root)));
1905 results.duplicate_exports.retain(|item| {
1906 let mut locations: Vec<String> = item
1907 .export
1908 .locations
1909 .iter()
1910 .map(|loc| relative_key_path(&loc.path, root))
1911 .collect();
1912 locations.sort();
1913 locations.dedup();
1914 keep(format!(
1915 "duplicate-export:{}:{}",
1916 item.export.export_name,
1917 locations.join("|")
1918 ))
1919 });
1920 results.type_only_dependencies.retain(|item| {
1921 keep(format!(
1922 "type-only-dependency:{}:{}",
1923 relative_key_path(&item.dep.path, root),
1924 item.dep.package_name
1925 ))
1926 });
1927 results.test_only_dependencies.retain(|item| {
1928 keep(format!(
1929 "test-only-dependency:{}:{}",
1930 relative_key_path(&item.dep.path, root),
1931 item.dep.package_name
1932 ))
1933 });
1934 results.circular_dependencies.retain(|item| {
1935 let mut files: Vec<String> = item
1936 .cycle
1937 .files
1938 .iter()
1939 .map(|path| relative_key_path(path, root))
1940 .collect();
1941 files.sort();
1942 keep(format!("circular-dependency:{}", files.join("|")))
1943 });
1944 results.re_export_cycles.retain(|item| {
1945 let kind = match item.cycle.kind {
1946 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
1947 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
1948 };
1949 let mut files: Vec<String> = item
1950 .cycle
1951 .files
1952 .iter()
1953 .map(|path| relative_key_path(path, root))
1954 .collect();
1955 files.sort();
1956 keep(format!("re-export-cycle:{kind}:{}", files.join("|")))
1957 });
1958 results.boundary_violations.retain(|item| {
1959 keep(format!(
1960 "boundary-violation:{}:{}:{}",
1961 relative_key_path(&item.violation.from_path, root),
1962 relative_key_path(&item.violation.to_path, root),
1963 item.violation.import_specifier
1964 ))
1965 });
1966 results.stale_suppressions.retain(|item| {
1967 keep(format!(
1968 "stale-suppression:{}:{}",
1969 relative_key_path(&item.path, root),
1970 item.description()
1971 ))
1972 });
1973 results.unresolved_catalog_references.retain(|item| {
1974 keep(format!(
1975 "unresolved-catalog-reference:{}:{}:{}:{}",
1976 relative_key_path(&item.reference.path, root),
1977 item.reference.line,
1978 item.reference.catalog_name,
1979 item.reference.entry_name
1980 ))
1981 });
1982 results
1983 .unused_catalog_entries
1984 .retain(|item| keep(unused_catalog_entry_key(&item.entry, root)));
1985 results
1986 .empty_catalog_groups
1987 .retain(|item| keep(empty_catalog_group_key(&item.group, root)));
1988 results.unused_dependency_overrides.retain(|item| {
1989 keep(format!(
1990 "unused-dependency-override:{}:{}:{}",
1991 relative_key_path(&item.entry.path, root),
1992 item.entry.line,
1993 item.entry.raw_key
1994 ))
1995 });
1996 results.misconfigured_dependency_overrides.retain(|item| {
1997 keep(format!(
1998 "misconfigured-dependency-override:{}:{}:{}",
1999 relative_key_path(&item.entry.path, root),
2000 item.entry.line,
2001 item.entry.raw_key
2002 ))
2003 });
2004}
2005
2006fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
2007 !base.contains(key)
2008}
2009
2010fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
2011where
2012 I: IntoIterator<Item = bool>,
2013{
2014 let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
2015 return;
2016 };
2017 for (item, introduced) in items.iter_mut().zip(introduced) {
2018 if let serde_json::Value::Object(map) = item {
2019 map.insert("introduced".to_string(), serde_json::json!(introduced));
2020 }
2021 }
2022}
2023
2024#[expect(
2025 clippy::too_many_lines,
2026 reason = "keeps audit attribution keys adjacent to the JSON arrays they annotate"
2027)]
2028fn annotate_dead_code_json(
2029 json: &mut serde_json::Value,
2030 results: &fallow_core::results::AnalysisResults,
2031 root: &Path,
2032 base: &FxHashSet<String>,
2033) {
2034 annotate_issue_array(
2035 json,
2036 "unused_files",
2037 results.unused_files.iter().map(|item| {
2038 issue_was_introduced(
2039 &format!("unused-file:{}", relative_key_path(&item.file.path, root)),
2040 base,
2041 )
2042 }),
2043 );
2044 annotate_issue_array(
2045 json,
2046 "unused_exports",
2047 results.unused_exports.iter().map(|item| {
2048 issue_was_introduced(
2049 &format!(
2050 "unused-export:{}:{}",
2051 relative_key_path(&item.export.path, root),
2052 item.export.export_name
2053 ),
2054 base,
2055 )
2056 }),
2057 );
2058 annotate_issue_array(
2059 json,
2060 "unused_types",
2061 results.unused_types.iter().map(|item| {
2062 issue_was_introduced(
2063 &format!(
2064 "unused-type:{}:{}",
2065 relative_key_path(&item.export.path, root),
2066 item.export.export_name
2067 ),
2068 base,
2069 )
2070 }),
2071 );
2072 annotate_issue_array(
2073 json,
2074 "private_type_leaks",
2075 results.private_type_leaks.iter().map(|item| {
2076 issue_was_introduced(
2077 &format!(
2078 "private-type-leak:{}:{}:{}",
2079 relative_key_path(&item.leak.path, root),
2080 item.leak.export_name,
2081 item.leak.type_name
2082 ),
2083 base,
2084 )
2085 }),
2086 );
2087 annotate_issue_array(
2088 json,
2089 "unused_dependencies",
2090 results
2091 .unused_dependencies
2092 .iter()
2093 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2094 );
2095 annotate_issue_array(
2096 json,
2097 "unused_dev_dependencies",
2098 results
2099 .unused_dev_dependencies
2100 .iter()
2101 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2102 );
2103 annotate_issue_array(
2104 json,
2105 "unused_optional_dependencies",
2106 results
2107 .unused_optional_dependencies
2108 .iter()
2109 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2110 );
2111 annotate_issue_array(
2112 json,
2113 "unused_enum_members",
2114 results.unused_enum_members.iter().map(|item| {
2115 issue_was_introduced(
2116 &unused_member_key("unused-enum-member", &item.member, root),
2117 base,
2118 )
2119 }),
2120 );
2121 annotate_issue_array(
2122 json,
2123 "unused_class_members",
2124 results.unused_class_members.iter().map(|item| {
2125 issue_was_introduced(
2126 &unused_member_key("unused-class-member", &item.member, root),
2127 base,
2128 )
2129 }),
2130 );
2131 annotate_issue_array(
2132 json,
2133 "unresolved_imports",
2134 results.unresolved_imports.iter().map(|item| {
2135 issue_was_introduced(
2136 &format!(
2137 "unresolved-import:{}:{}",
2138 relative_key_path(&item.import.path, root),
2139 item.import.specifier
2140 ),
2141 base,
2142 )
2143 }),
2144 );
2145 annotate_issue_array(
2146 json,
2147 "unlisted_dependencies",
2148 results
2149 .unlisted_dependencies
2150 .iter()
2151 .map(|item| issue_was_introduced(&unlisted_dependency_key(&item.dep, root), base)),
2152 );
2153 annotate_issue_array(
2154 json,
2155 "duplicate_exports",
2156 results.duplicate_exports.iter().map(|item| {
2157 let mut locations: Vec<String> = item
2158 .export
2159 .locations
2160 .iter()
2161 .map(|loc| relative_key_path(&loc.path, root))
2162 .collect();
2163 locations.sort();
2164 locations.dedup();
2165 issue_was_introduced(
2166 &format!(
2167 "duplicate-export:{}:{}",
2168 item.export.export_name,
2169 locations.join("|")
2170 ),
2171 base,
2172 )
2173 }),
2174 );
2175 annotate_issue_array(
2176 json,
2177 "type_only_dependencies",
2178 results.type_only_dependencies.iter().map(|item| {
2179 issue_was_introduced(
2180 &format!(
2181 "type-only-dependency:{}:{}",
2182 relative_key_path(&item.dep.path, root),
2183 item.dep.package_name
2184 ),
2185 base,
2186 )
2187 }),
2188 );
2189 annotate_issue_array(
2190 json,
2191 "test_only_dependencies",
2192 results.test_only_dependencies.iter().map(|item| {
2193 issue_was_introduced(
2194 &format!(
2195 "test-only-dependency:{}:{}",
2196 relative_key_path(&item.dep.path, root),
2197 item.dep.package_name
2198 ),
2199 base,
2200 )
2201 }),
2202 );
2203 annotate_issue_array(
2204 json,
2205 "circular_dependencies",
2206 results.circular_dependencies.iter().map(|item| {
2207 let mut files: Vec<String> = item
2208 .cycle
2209 .files
2210 .iter()
2211 .map(|path| relative_key_path(path, root))
2212 .collect();
2213 files.sort();
2214 issue_was_introduced(&format!("circular-dependency:{}", files.join("|")), base)
2215 }),
2216 );
2217 annotate_issue_array(
2218 json,
2219 "re_export_cycles",
2220 results.re_export_cycles.iter().map(|item| {
2221 let kind = match item.cycle.kind {
2222 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
2223 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
2224 };
2225 let mut files: Vec<String> = item
2226 .cycle
2227 .files
2228 .iter()
2229 .map(|path| relative_key_path(path, root))
2230 .collect();
2231 files.sort();
2232 issue_was_introduced(&format!("re-export-cycle:{kind}:{}", files.join("|")), base)
2233 }),
2234 );
2235 annotate_issue_array(
2236 json,
2237 "boundary_violations",
2238 results.boundary_violations.iter().map(|item| {
2239 issue_was_introduced(
2240 &format!(
2241 "boundary-violation:{}:{}:{}",
2242 relative_key_path(&item.violation.from_path, root),
2243 relative_key_path(&item.violation.to_path, root),
2244 item.violation.import_specifier
2245 ),
2246 base,
2247 )
2248 }),
2249 );
2250 annotate_issue_array(
2251 json,
2252 "stale_suppressions",
2253 results.stale_suppressions.iter().map(|item| {
2254 issue_was_introduced(
2255 &format!(
2256 "stale-suppression:{}:{}",
2257 relative_key_path(&item.path, root),
2258 item.description()
2259 ),
2260 base,
2261 )
2262 }),
2263 );
2264 annotate_issue_array(
2265 json,
2266 "unresolved_catalog_references",
2267 results.unresolved_catalog_references.iter().map(|item| {
2268 issue_was_introduced(
2269 &format!(
2270 "unresolved-catalog-reference:{}:{}:{}:{}",
2271 relative_key_path(&item.reference.path, root),
2272 item.reference.line,
2273 item.reference.catalog_name,
2274 item.reference.entry_name
2275 ),
2276 base,
2277 )
2278 }),
2279 );
2280 annotate_issue_array(
2281 json,
2282 "unused_catalog_entries",
2283 results
2284 .unused_catalog_entries
2285 .iter()
2286 .map(|item| issue_was_introduced(&unused_catalog_entry_key(&item.entry, root), base)),
2287 );
2288 annotate_issue_array(
2289 json,
2290 "empty_catalog_groups",
2291 results
2292 .empty_catalog_groups
2293 .iter()
2294 .map(|item| issue_was_introduced(&empty_catalog_group_key(&item.group, root), base)),
2295 );
2296 annotate_issue_array(
2297 json,
2298 "unused_dependency_overrides",
2299 results.unused_dependency_overrides.iter().map(|item| {
2300 issue_was_introduced(
2301 &format!(
2302 "unused-dependency-override:{}:{}:{}",
2303 relative_key_path(&item.entry.path, root),
2304 item.entry.line,
2305 item.entry.raw_key
2306 ),
2307 base,
2308 )
2309 }),
2310 );
2311 annotate_issue_array(
2312 json,
2313 "misconfigured_dependency_overrides",
2314 results
2315 .misconfigured_dependency_overrides
2316 .iter()
2317 .map(|item| {
2318 issue_was_introduced(
2319 &format!(
2320 "misconfigured-dependency-override:{}:{}:{}",
2321 relative_key_path(&item.entry.path, root),
2322 item.entry.line,
2323 item.entry.raw_key
2324 ),
2325 base,
2326 )
2327 }),
2328 );
2329}
2330
2331fn annotate_health_json(
2332 json: &mut serde_json::Value,
2333 report: &crate::health_types::HealthReport,
2334 root: &Path,
2335 base: &FxHashSet<String>,
2336) {
2337 let Some(items) = json
2338 .get_mut("findings")
2339 .and_then(serde_json::Value::as_array_mut)
2340 else {
2341 return;
2342 };
2343 for (item, finding) in items.iter_mut().zip(&report.findings) {
2344 if let serde_json::Value::Object(map) = item {
2345 map.insert(
2346 "introduced".to_string(),
2347 serde_json::json!(issue_was_introduced(
2348 &health_finding_key(finding, root),
2349 base
2350 )),
2351 );
2352 }
2353 }
2354}
2355
2356fn annotate_dupes_json(
2357 json: &mut serde_json::Value,
2358 report: &fallow_core::duplicates::DuplicationReport,
2359 root: &Path,
2360 base: &FxHashSet<String>,
2361) {
2362 let Some(items) = json
2363 .get_mut("clone_groups")
2364 .and_then(serde_json::Value::as_array_mut)
2365 else {
2366 return;
2367 };
2368 for (item, group) in items.iter_mut().zip(&report.clone_groups) {
2369 if let serde_json::Value::Object(map) = item {
2370 map.insert(
2371 "introduced".to_string(),
2372 serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
2373 );
2374 }
2375 }
2376}
2377
2378fn health_keys(report: &crate::health_types::HealthReport, root: &Path) -> FxHashSet<String> {
2379 report
2380 .findings
2381 .iter()
2382 .map(|finding| health_finding_key(finding, root))
2383 .collect()
2384}
2385
2386fn health_finding_key(finding: &crate::health_types::ComplexityViolation, root: &Path) -> String {
2387 format!(
2388 "complexity:{}:{}:{:?}",
2389 relative_key_path(&finding.path, root),
2390 finding.name,
2391 finding.exceeded
2392 )
2393}
2394
2395fn dupes_keys(
2396 report: &fallow_core::duplicates::DuplicationReport,
2397 root: &Path,
2398) -> FxHashSet<String> {
2399 report
2400 .clone_groups
2401 .iter()
2402 .map(|group| dupe_group_key(group, root))
2403 .collect()
2404}
2405
2406fn dupe_group_key(group: &fallow_core::duplicates::CloneGroup, root: &Path) -> String {
2407 let mut files: Vec<String> = group
2408 .instances
2409 .iter()
2410 .map(|instance| relative_key_path(&instance.file, root))
2411 .collect();
2412 files.sort();
2413 files.dedup();
2414 let mut hasher = DefaultHasher::new();
2415 for instance in &group.instances {
2416 instance.fragment.hash(&mut hasher);
2417 }
2418 format!(
2419 "dupe:{}:{}:{}:{:x}",
2420 files.join("|"),
2421 group.token_count,
2422 group.line_count,
2423 hasher.finish()
2424 )
2425}
2426
2427struct HeadAnalyses {
2432 check: Option<CheckResult>,
2433 dupes: Option<DupesResult>,
2434 health: Option<HealthResult>,
2435}
2436
2437fn run_audit_head_analyses(
2444 opts: &AuditOptions<'_>,
2445 changed_since: Option<&str>,
2446 changed_files: &FxHashSet<PathBuf>,
2447) -> Result<HeadAnalyses, ExitCode> {
2448 let check_production = opts.production_dead_code.unwrap_or(opts.production);
2449 let health_production = opts.production_health.unwrap_or(opts.production);
2450 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
2451 let share_dead_code_parse_with_health = check_production == health_production;
2452 let share_dead_code_files_with_dupes =
2453 share_dead_code_parse_with_health && check_production == dupes_production;
2454
2455 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
2456 let dupes_files = if share_dead_code_files_with_dupes {
2457 check
2458 .as_ref()
2459 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
2460 } else {
2461 None
2462 };
2463 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
2464 let shared_parse = if share_dead_code_parse_with_health {
2465 check.as_mut().and_then(|r| r.shared_parse.take())
2466 } else {
2467 None
2468 };
2469 let health = run_audit_health(opts, changed_since, shared_parse)?;
2470 Ok(HeadAnalyses {
2471 check,
2472 dupes,
2473 health,
2474 })
2475}
2476
2477pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
2479 let start = Instant::now();
2480
2481 let base_ref = resolve_base_ref(opts)?;
2482
2483 if let Some(max_age) = resolve_cache_max_age(opts) {
2484 sweep_old_reusable_caches(opts.root, max_age, opts.quiet);
2485 }
2486
2487 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
2488 return Err(emit_error(
2489 &format!(
2490 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
2491 ),
2492 2,
2493 opts.output,
2494 ));
2495 };
2496 let changed_files_count = changed_files.len();
2497
2498 if changed_files.is_empty() {
2499 return Ok(empty_audit_result(base_ref, opts, start.elapsed()));
2500 }
2501
2502 let changed_since = Some(base_ref.as_str());
2503
2504 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
2505 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
2506 let base_cache_key = if needs_real_base_snapshot {
2507 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
2508 } else {
2509 None
2510 };
2511 let cached_base_snapshot = base_cache_key
2512 .as_ref()
2513 .and_then(|key| load_cached_base_snapshot(opts, key));
2514
2515 let (head_res, base_res) = if needs_real_base_snapshot && cached_base_snapshot.is_none() {
2516 let base_sha = base_cache_key.as_ref().map(|key| key.base_sha.as_str());
2517 let (h, b) = rayon::join(
2518 || run_audit_head_analyses(opts, changed_since, &changed_files),
2519 || compute_base_snapshot(opts, &base_ref, &changed_files, base_sha),
2520 );
2521 (h, Some(b))
2522 } else {
2523 (
2524 run_audit_head_analyses(opts, changed_since, &changed_files),
2525 None,
2526 )
2527 };
2528
2529 let head = head_res?;
2530 let mut check_result = head.check;
2531 let dupes_result = head.dupes;
2532 let health_result = head.health;
2533
2534 let (base_snapshot, base_snapshot_skipped) = if matches!(opts.gate, AuditGate::NewOnly) {
2535 if let Some(snapshot) = cached_base_snapshot {
2536 (Some(snapshot), false)
2537 } else if let Some(base_res) = base_res {
2538 let snapshot = base_res?;
2539 if let Some(ref key) = base_cache_key {
2540 save_cached_base_snapshot(opts, key, &snapshot);
2541 }
2542 (Some(snapshot), false)
2543 } else {
2544 (
2545 Some(current_keys_as_base_keys(
2546 check_result.as_ref(),
2547 dupes_result.as_ref(),
2548 health_result.as_ref(),
2549 )),
2550 true,
2551 )
2552 }
2553 } else {
2554 (None, false)
2555 };
2556 if let Some(ref mut check) = check_result {
2557 check.shared_parse = None;
2558 }
2559 let attribution = compute_audit_attribution(
2560 check_result.as_ref(),
2561 dupes_result.as_ref(),
2562 health_result.as_ref(),
2563 base_snapshot.as_ref(),
2564 opts.gate,
2565 );
2566 let verdict = if matches!(opts.gate, AuditGate::NewOnly) {
2567 compute_introduced_verdict(
2568 check_result.as_ref(),
2569 dupes_result.as_ref(),
2570 health_result.as_ref(),
2571 base_snapshot.as_ref(),
2572 )
2573 } else {
2574 compute_verdict(
2575 check_result.as_ref(),
2576 dupes_result.as_ref(),
2577 health_result.as_ref(),
2578 )
2579 };
2580 let summary = build_summary(
2581 check_result.as_ref(),
2582 dupes_result.as_ref(),
2583 health_result.as_ref(),
2584 );
2585
2586 Ok(AuditResult {
2587 verdict,
2588 summary,
2589 attribution,
2590 base_snapshot,
2591 base_snapshot_skipped,
2592 changed_files_count,
2593 changed_files: changed_files.into_iter().collect(),
2594 base_ref,
2595 head_sha: get_head_sha(opts.root),
2596 output: opts.output,
2597 performance: opts.performance,
2598 check: check_result,
2599 dupes: dupes_result,
2600 health: health_result,
2601 elapsed: start.elapsed(),
2602 })
2603}
2604
2605fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<String, ExitCode> {
2607 if let Some(ref_str) = opts.changed_since {
2608 return Ok(ref_str.to_string());
2609 }
2610 let Some(branch) = auto_detect_base_branch(opts.root) else {
2611 return Err(emit_error(
2612 "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
2613 2,
2614 opts.output,
2615 ));
2616 };
2617 if let Err(e) = crate::validate::validate_git_ref(&branch) {
2618 return Err(emit_error(
2619 &format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"),
2620 2,
2621 opts.output,
2622 ));
2623 }
2624 Ok(branch)
2625}
2626
2627fn empty_audit_result(base_ref: String, opts: &AuditOptions<'_>, elapsed: Duration) -> AuditResult {
2629 AuditResult {
2630 verdict: AuditVerdict::Pass,
2631 summary: AuditSummary {
2632 dead_code_issues: 0,
2633 dead_code_has_errors: false,
2634 complexity_findings: 0,
2635 max_cyclomatic: None,
2636 duplication_clone_groups: 0,
2637 },
2638 attribution: AuditAttribution {
2639 gate: opts.gate,
2640 ..AuditAttribution::default()
2641 },
2642 base_snapshot: None,
2643 base_snapshot_skipped: false,
2644 changed_files_count: 0,
2645 changed_files: Vec::new(),
2646 base_ref,
2647 head_sha: get_head_sha(opts.root),
2648 output: opts.output,
2649 performance: opts.performance,
2650 check: None,
2651 dupes: None,
2652 health: None,
2653 elapsed,
2654 }
2655}
2656
2657fn run_audit_check<'a>(
2659 opts: &'a AuditOptions<'a>,
2660 changed_since: Option<&'a str>,
2661 retain_modules_for_health: bool,
2662) -> Result<Option<CheckResult>, ExitCode> {
2663 let filters = IssueFilters::default();
2664 let trace_opts = TraceOptions {
2665 trace_export: None,
2666 trace_file: None,
2667 trace_dependency: None,
2668 performance: opts.performance,
2669 };
2670 match crate::check::execute_check(&CheckOptions {
2671 root: opts.root,
2672 config_path: opts.config_path,
2673 output: opts.output,
2674 no_cache: opts.no_cache,
2675 threads: opts.threads,
2676 quiet: opts.quiet,
2677 fail_on_issues: false,
2678 filters: &filters,
2679 changed_since,
2680 diff_index: None,
2681 use_shared_diff_index: true,
2682 baseline: opts.dead_code_baseline,
2683 save_baseline: None,
2684 sarif_file: None,
2685 production: opts.production_dead_code.unwrap_or(opts.production),
2686 production_override: opts.production_dead_code,
2687 workspace: opts.workspace,
2688 changed_workspaces: opts.changed_workspaces,
2689 group_by: opts.group_by,
2690 include_dupes: false,
2691 trace_opts: &trace_opts,
2692 explain: opts.explain,
2693 top: None,
2694 file: &[],
2695 include_entry_exports: opts.include_entry_exports,
2696 summary: false,
2697 regression_opts: crate::regression::RegressionOpts {
2698 fail_on_regression: false,
2699 tolerance: crate::regression::Tolerance::Absolute(0),
2700 regression_baseline_file: None,
2701 save_target: crate::regression::SaveRegressionTarget::None,
2702 scoped: true,
2703 quiet: opts.quiet,
2704 output: opts.output,
2705 },
2706 retain_modules_for_health,
2707 defer_performance: false,
2708 }) {
2709 Ok(r) => Ok(Some(r)),
2710 Err(code) => Err(code),
2711 }
2712}
2713
2714fn run_audit_dupes<'a>(
2720 opts: &'a AuditOptions<'a>,
2721 changed_since: Option<&'a str>,
2722 changed_files: Option<&'a FxHashSet<PathBuf>>,
2723 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2724) -> Result<Option<DupesResult>, ExitCode> {
2725 let dupes_cfg = match crate::load_config_for_analysis(
2726 opts.root,
2727 opts.config_path,
2728 opts.output,
2729 opts.no_cache,
2730 opts.threads,
2731 opts.production_dupes
2732 .or_else(|| opts.production.then_some(true)),
2733 opts.quiet,
2734 fallow_config::ProductionAnalysis::Dupes,
2735 ) {
2736 Ok(c) => c.duplicates,
2737 Err(code) => return Err(code),
2738 };
2739 let dupes_opts = DupesOptions {
2740 root: opts.root,
2741 config_path: opts.config_path,
2742 output: opts.output,
2743 no_cache: opts.no_cache,
2744 threads: opts.threads,
2745 quiet: opts.quiet,
2746 mode: Some(DupesMode::from(dupes_cfg.mode)),
2747 min_tokens: Some(dupes_cfg.min_tokens),
2748 min_lines: Some(dupes_cfg.min_lines),
2749 min_occurrences: Some(dupes_cfg.min_occurrences),
2750 threshold: Some(dupes_cfg.threshold),
2751 skip_local: dupes_cfg.skip_local,
2752 cross_language: dupes_cfg.cross_language,
2753 ignore_imports: dupes_cfg.ignore_imports,
2754 top: None,
2755 baseline_path: opts.dupes_baseline,
2756 save_baseline_path: None,
2757 production: opts.production_dupes.unwrap_or(opts.production),
2758 production_override: opts.production_dupes,
2759 trace: None,
2760 changed_since,
2761 diff_index: None,
2762 use_shared_diff_index: true,
2763 changed_files,
2764 workspace: opts.workspace,
2765 changed_workspaces: opts.changed_workspaces,
2766 explain: opts.explain,
2767 explain_skipped: opts.explain_skipped,
2768 summary: false,
2769 group_by: opts.group_by,
2770 performance: false,
2771 };
2772 let dupes_run = if let Some(files) = pre_discovered {
2773 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2774 } else {
2775 crate::dupes::execute_dupes(&dupes_opts)
2776 };
2777 match dupes_run {
2778 Ok(r) => Ok(Some(r)),
2779 Err(code) => Err(code),
2780 }
2781}
2782
2783fn run_audit_health<'a>(
2785 opts: &'a AuditOptions<'a>,
2786 changed_since: Option<&'a str>,
2787 shared_parse: Option<crate::health::SharedParseData>,
2788) -> Result<Option<HealthResult>, ExitCode> {
2789 let runtime_coverage = match opts.runtime_coverage {
2790 Some(path) => match crate::health::coverage::prepare_options(
2791 path,
2792 opts.min_invocations_hot,
2793 None,
2794 None,
2795 opts.output,
2796 ) {
2797 Ok(options) => Some(options),
2798 Err(code) => return Err(code),
2799 },
2800 None => None,
2801 };
2802
2803 let health_opts = HealthOptions {
2804 root: opts.root,
2805 config_path: opts.config_path,
2806 output: opts.output,
2807 no_cache: opts.no_cache,
2808 threads: opts.threads,
2809 quiet: opts.quiet,
2810 max_cyclomatic: None,
2811 max_cognitive: None,
2812 max_crap: opts.max_crap,
2813 top: None,
2814 sort: SortBy::Cyclomatic,
2815 production: opts.production_health.unwrap_or(opts.production),
2816 production_override: opts.production_health,
2817 changed_since,
2818 diff_index: None,
2819 use_shared_diff_index: true,
2820 workspace: opts.workspace,
2821 changed_workspaces: opts.changed_workspaces,
2822 baseline: opts.health_baseline,
2823 save_baseline: None,
2824 complexity: true,
2825 file_scores: false,
2826 coverage_gaps: false,
2827 config_activates_coverage_gaps: false,
2828 hotspots: false,
2829 ownership: false,
2830 ownership_emails: None,
2831 targets: false,
2832 force_full: false,
2833 score_only_output: false,
2834 enforce_coverage_gap_gate: false,
2835 effort: None,
2836 score: false,
2837 min_score: None,
2838 since: None,
2839 min_commits: None,
2840 explain: opts.explain,
2841 summary: false,
2842 save_snapshot: None,
2843 trend: false,
2844 group_by: opts.group_by,
2845 coverage: opts.coverage,
2846 coverage_root: opts.coverage_root,
2847 performance: opts.performance,
2848 min_severity: None,
2849 report_only: false,
2850 runtime_coverage,
2851 };
2852 let health_run = if let Some(shared) = shared_parse {
2853 crate::health::execute_health_with_shared_parse(&health_opts, shared)
2854 } else {
2855 crate::health::execute_health(&health_opts)
2856 };
2857 match health_run {
2858 Ok(r) => Ok(Some(r)),
2859 Err(code) => Err(code),
2860 }
2861}
2862
2863#[must_use]
2865pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
2866 let output = result.output;
2867
2868 let format_exit = match output {
2869 OutputFormat::Json => print_audit_json(result),
2870 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
2871 print_audit_human(result, quiet, explain, output);
2872 ExitCode::SUCCESS
2873 }
2874 OutputFormat::Sarif => print_audit_sarif(result),
2875 OutputFormat::CodeClimate => print_audit_codeclimate(result),
2876 OutputFormat::PrCommentGithub => {
2877 let value = build_audit_codeclimate(result);
2878 report::ci::pr_comment::print_pr_comment(
2879 "audit",
2880 report::ci::pr_comment::Provider::Github,
2881 &value,
2882 )
2883 }
2884 OutputFormat::PrCommentGitlab => {
2885 let value = build_audit_codeclimate(result);
2886 report::ci::pr_comment::print_pr_comment(
2887 "audit",
2888 report::ci::pr_comment::Provider::Gitlab,
2889 &value,
2890 )
2891 }
2892 OutputFormat::ReviewGithub => {
2893 let value = build_audit_codeclimate(result);
2894 report::ci::review::print_review_envelope(
2895 "audit",
2896 report::ci::pr_comment::Provider::Github,
2897 &value,
2898 )
2899 }
2900 OutputFormat::ReviewGitlab => {
2901 let value = build_audit_codeclimate(result);
2902 report::ci::review::print_review_envelope(
2903 "audit",
2904 report::ci::pr_comment::Provider::Gitlab,
2905 &value,
2906 )
2907 }
2908 OutputFormat::Badge => {
2909 eprintln!("Error: badge format is not supported for the audit command");
2910 return ExitCode::from(2);
2911 }
2912 };
2913
2914 if format_exit != ExitCode::SUCCESS {
2915 return format_exit;
2916 }
2917
2918 match result.verdict {
2919 AuditVerdict::Fail => ExitCode::from(1),
2920 AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
2921 }
2922}
2923
2924fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
2925 let show_headers = matches!(output, OutputFormat::Human) && !quiet;
2926
2927 if !quiet {
2928 let scope = format_scope_line(result);
2929 eprintln!();
2930 eprintln!("{scope}");
2931 }
2932
2933 let has_check_issues = result.summary.dead_code_issues > 0;
2934 let has_health_findings = result.summary.complexity_findings > 0;
2935 let has_dupe_groups = result.summary.duplication_clone_groups > 0;
2936 let has_any_findings = has_check_issues || has_health_findings || has_dupe_groups;
2937
2938 if has_any_findings {
2939 if show_headers && std::io::stdout().is_terminal() {
2940 println!(
2941 "{}",
2942 "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
2943 .dimmed()
2944 );
2945 println!();
2946 }
2947
2948 if result.verdict != AuditVerdict::Fail && !quiet {
2949 print_audit_vital_signs(result);
2950 }
2951
2952 if has_check_issues && let Some(ref check) = result.check {
2953 if show_headers {
2954 eprintln!();
2955 eprintln!("── Dead Code ──────────────────────────────────────");
2956 }
2957 crate::check::print_check_result(
2958 check,
2959 crate::check::PrintCheckOptions {
2960 quiet,
2961 explain,
2962 regression_json: false,
2963 group_by: None,
2964 top: None,
2965 summary: false,
2966 summary_heading: true,
2967 show_explain_tip: false,
2968 },
2969 );
2970 }
2971
2972 if has_dupe_groups && let Some(ref dupes) = result.dupes {
2973 if show_headers {
2974 eprintln!();
2975 eprintln!("── Duplication ────────────────────────────────────");
2976 }
2977 crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
2978 }
2979
2980 if has_health_findings && let Some(ref health) = result.health {
2981 if show_headers {
2982 eprintln!();
2983 eprintln!("── Complexity ─────────────────────────────────────");
2984 }
2985 crate::health::print_health_result(
2986 health, quiet, explain, None, None, false, false, true, false, false,
2987 );
2988 }
2989 }
2990
2991 if !has_dupe_groups && let Some(ref dupes) = result.dupes {
2992 crate::dupes::print_default_ignore_note(dupes, quiet);
2993 crate::dupes::print_min_occurrences_note(dupes, quiet);
2994 }
2995
2996 if !quiet {
2997 print_audit_status_line(result);
2998 }
2999}
3000
3001fn format_scope_line(result: &AuditResult) -> String {
3003 let sha_suffix = result
3004 .head_sha
3005 .as_ref()
3006 .map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
3007 format!(
3008 "Audit scope: {} changed file{} vs {}{}",
3009 result.changed_files_count,
3010 plural(result.changed_files_count),
3011 result.base_ref,
3012 sha_suffix
3013 )
3014}
3015
3016fn print_audit_vital_signs(result: &AuditResult) {
3018 let mut parts = Vec::new();
3019 parts.push(format!("dead code {}", result.summary.dead_code_issues));
3020 if let Some(max) = result.summary.max_cyclomatic {
3021 parts.push(format!(
3022 "complexity {} (warn, max cyclomatic: {max})",
3023 result.summary.complexity_findings
3024 ));
3025 } else {
3026 parts.push(format!("complexity {}", result.summary.complexity_findings));
3027 }
3028 parts.push(format!(
3029 "duplication {}",
3030 result.summary.duplication_clone_groups
3031 ));
3032
3033 let line = parts.join(" \u{00b7} ");
3034 println!(
3035 "{} {} {}",
3036 "\u{25a0}".dimmed(),
3037 "Metrics:".dimmed(),
3038 line.dimmed()
3039 );
3040}
3041
3042fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
3044 let mut parts = Vec::new();
3045 if summary.dead_code_issues > 0 {
3046 let n = summary.dead_code_issues;
3047 parts.push(format!("dead code: {n} issue{}", plural(n)));
3048 }
3049 if summary.complexity_findings > 0 {
3050 let n = summary.complexity_findings;
3051 parts.push(format!("complexity: {n} finding{}", plural(n)));
3052 }
3053 if summary.duplication_clone_groups > 0 {
3054 let n = summary.duplication_clone_groups;
3055 parts.push(format!("duplication: {n} clone group{}", plural(n)));
3056 }
3057 parts
3058}
3059
3060fn print_audit_status_line(result: &AuditResult) {
3062 let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
3063 let n = result.changed_files_count;
3064 let files_str = format!("{n} changed file{}", plural(n));
3065
3066 match result.verdict {
3067 AuditVerdict::Pass => {
3068 eprintln!(
3069 "{}",
3070 format!("\u{2713} No issues in {files_str} ({elapsed_str})")
3071 .green()
3072 .bold()
3073 );
3074 }
3075 AuditVerdict::Warn => {
3076 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
3077 eprintln!(
3078 "{}",
3079 format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
3080 .green()
3081 .bold()
3082 );
3083 }
3084 AuditVerdict::Fail => {
3085 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
3086 eprintln!(
3087 "{}",
3088 format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
3089 .red()
3090 .bold()
3091 );
3092 }
3093 }
3094
3095 if !matches!(result.attribution.gate, AuditGate::All) {
3096 let inherited = result.attribution.dead_code_inherited
3097 + result.attribution.complexity_inherited
3098 + result.attribution.duplication_inherited;
3099 if inherited > 0 {
3100 eprintln!(
3101 " {}",
3102 format!(
3103 "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
3104 plural(inherited)
3105 )
3106 .dimmed()
3107 );
3108 }
3109 }
3110 if result.performance {
3111 eprintln!(
3112 " {}",
3113 format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
3114 );
3115 }
3116}
3117
3118#[expect(
3119 clippy::cast_possible_truncation,
3120 reason = "elapsed milliseconds won't exceed u64::MAX"
3121)]
3122fn print_audit_json(result: &AuditResult) -> ExitCode {
3123 let mut obj = serde_json::Map::new();
3124 obj.insert(
3125 "schema_version".into(),
3126 serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
3127 );
3128 obj.insert(
3129 "version".into(),
3130 serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
3131 );
3132 obj.insert(
3133 "command".into(),
3134 serde_json::Value::String("audit".to_string()),
3135 );
3136 obj.insert(
3137 "verdict".into(),
3138 serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
3139 );
3140 obj.insert(
3141 "changed_files_count".into(),
3142 serde_json::Value::Number(result.changed_files_count.into()),
3143 );
3144 obj.insert(
3145 "base_ref".into(),
3146 serde_json::Value::String(result.base_ref.clone()),
3147 );
3148 if let Some(ref sha) = result.head_sha {
3149 obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
3150 }
3151 obj.insert(
3152 "elapsed_ms".into(),
3153 serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
3154 );
3155 if result.performance {
3156 obj.insert(
3157 "base_snapshot_skipped".into(),
3158 serde_json::Value::Bool(result.base_snapshot_skipped),
3159 );
3160 }
3161
3162 if let Ok(summary_val) = serde_json::to_value(&result.summary) {
3163 obj.insert("summary".into(), summary_val);
3164 }
3165 if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
3166 obj.insert("attribution".into(), attribution_val);
3167 }
3168
3169 if let Some(ref check) = result.check {
3170 match report::build_check_json_payload_with_config_fixable(
3171 &check.results,
3172 &check.config.root,
3173 check.elapsed,
3174 check.config_fixable,
3175 ) {
3176 Ok(mut json) => {
3177 if let Some(ref base) = result.base_snapshot {
3178 annotate_dead_code_json(
3179 &mut json,
3180 &check.results,
3181 &check.config.root,
3182 &base.dead_code,
3183 );
3184 }
3185 obj.insert("dead_code".into(), json);
3186 }
3187 Err(e) => {
3188 return emit_error(
3189 &format!("JSON serialization error: {e}"),
3190 2,
3191 OutputFormat::Json,
3192 );
3193 }
3194 }
3195 }
3196
3197 if let Some(ref dupes) = result.dupes {
3198 let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
3199 match serde_json::to_value(&payload) {
3200 Ok(mut json) => {
3201 let root_prefix = format!("{}/", dupes.config.root.display());
3202 report::strip_root_prefix(&mut json, &root_prefix);
3203 if let Some(ref base) = result.base_snapshot {
3204 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
3205 }
3206 obj.insert("duplication".into(), json);
3207 }
3208 Err(e) => {
3209 return emit_error(
3210 &format!("JSON serialization error: {e}"),
3211 2,
3212 OutputFormat::Json,
3213 );
3214 }
3215 }
3216 }
3217
3218 if let Some(ref health) = result.health {
3219 match serde_json::to_value(&health.report) {
3220 Ok(mut json) => {
3221 let root_prefix = format!("{}/", health.config.root.display());
3222 report::strip_root_prefix(&mut json, &root_prefix);
3223 if let Some(ref base) = result.base_snapshot {
3224 annotate_health_json(
3225 &mut json,
3226 &health.report,
3227 &health.config.root,
3228 &base.health,
3229 );
3230 }
3231 obj.insert("complexity".into(), json);
3232 }
3233 Err(e) => {
3234 return emit_error(
3235 &format!("JSON serialization error: {e}"),
3236 2,
3237 OutputFormat::Json,
3238 );
3239 }
3240 }
3241 }
3242
3243 let mut output = serde_json::Value::Object(obj);
3244 crate::output_envelope::apply_root_kind(&mut output, "audit");
3245 report::harmonize_multi_kind_suppress_line_actions(&mut output);
3246 report::emit_json(&output, "audit")
3247}
3248
3249fn print_audit_sarif(result: &AuditResult) -> ExitCode {
3250 let mut all_runs = Vec::new();
3251
3252 if let Some(ref check) = result.check {
3253 let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
3254 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
3255 all_runs.extend(runs.iter().cloned());
3256 }
3257 }
3258
3259 if let Some(ref dupes) = result.dupes
3260 && !dupes.report.clone_groups.is_empty()
3261 {
3262 let run = serde_json::json!({
3263 "tool": {
3264 "driver": {
3265 "name": "fallow",
3266 "version": env!("CARGO_PKG_VERSION"),
3267 "informationUri": "https://github.com/fallow-rs/fallow",
3268 }
3269 },
3270 "automationDetails": { "id": "fallow/audit/dupes" },
3271 "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
3272 serde_json::json!({
3273 "ruleId": "fallow/code-duplication",
3274 "level": "warning",
3275 "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
3276 })
3277 }).collect::<Vec<_>>()
3278 });
3279 all_runs.push(run);
3280 }
3281
3282 if let Some(ref health) = result.health {
3283 let sarif = report::build_health_sarif(&health.report, &health.config.root);
3284 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
3285 all_runs.extend(runs.iter().cloned());
3286 }
3287 }
3288
3289 let combined = serde_json::json!({
3290 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3291 "version": "2.1.0",
3292 "runs": all_runs,
3293 });
3294
3295 report::emit_json(&combined, "SARIF audit")
3296}
3297
3298fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
3299 let value = build_audit_codeclimate(result);
3300 report::emit_json(&value, "CodeClimate audit")
3301}
3302
3303#[expect(
3304 clippy::expect_used,
3305 reason = "CodeClimate issue envelope contains only infallibly serializable fields"
3306)]
3307fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
3308 let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
3309
3310 if let Some(ref check) = result.check {
3311 all_issues.extend(report::build_codeclimate(
3312 &check.results,
3313 &check.config.root,
3314 &check.config.rules,
3315 ));
3316 }
3317
3318 if let Some(ref dupes) = result.dupes {
3319 all_issues.extend(report::build_duplication_codeclimate(
3320 &dupes.report,
3321 &dupes.config.root,
3322 ));
3323 }
3324
3325 if let Some(ref health) = result.health {
3326 all_issues.extend(report::build_health_codeclimate(
3327 &health.report,
3328 &health.config.root,
3329 ));
3330 }
3331
3332 serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
3333}
3334
3335pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
3341 if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
3342 return emit_error(&e, 2, opts.output);
3343 }
3344 let coverage_resolved = opts
3345 .coverage
3346 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
3347 let runtime_coverage_resolved = opts
3348 .runtime_coverage
3349 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
3350 let resolved_opts = AuditOptions {
3351 coverage: coverage_resolved.as_deref(),
3352 runtime_coverage: runtime_coverage_resolved.as_deref(),
3353 ..*opts
3354 };
3355 match execute_audit(&resolved_opts) {
3356 Ok(result) => {
3357 let mut findings = result
3358 .check
3359 .as_ref()
3360 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
3361 .unwrap_or_default();
3362 if let Some(health) = result.health.as_ref() {
3363 findings.extend(crate::impact::collect_complexity_findings(&health.report));
3364 }
3365 let clones = result
3366 .dupes
3367 .as_ref()
3368 .map(|d| crate::impact::collect_clone_findings(&d.report))
3369 .unwrap_or_default();
3370 let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
3371 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
3372 c.results.active_suppressions.as_slice()
3373 });
3374 let attribution = crate::impact::AttributionInput {
3375 root: opts.root,
3376 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
3377 findings,
3378 clones,
3379 suppressions,
3380 };
3381 crate::impact::record_audit_run(
3382 opts.root,
3383 &result.summary,
3384 result.verdict,
3385 gate_marker.is_some(),
3386 result.head_sha.as_deref(),
3387 env!("CARGO_PKG_VERSION"),
3388 &crate::vital_signs::chrono_timestamp(),
3389 Some(&attribution),
3390 );
3391 print_audit_result(&result, opts.quiet, opts.explain)
3392 }
3393 Err(code) => code,
3394 }
3395}
3396
3397#[cfg(test)]
3398mod tests {
3399 use super::*;
3400 use std::{fs, process::Command};
3401
3402 fn git(dir: &std::path::Path, args: &[&str]) {
3403 let output = Command::new("git")
3404 .args(args)
3405 .current_dir(dir)
3406 .env_remove("GIT_DIR")
3407 .env_remove("GIT_WORK_TREE")
3408 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3409 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3410 .env("GIT_AUTHOR_NAME", "test")
3411 .env("GIT_AUTHOR_EMAIL", "test@test.com")
3412 .env("GIT_COMMITTER_NAME", "test")
3413 .env("GIT_COMMITTER_EMAIL", "test@test.com")
3414 .output()
3415 .expect("git command failed");
3416 assert!(
3417 output.status.success(),
3418 "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
3419 args,
3420 String::from_utf8_lossy(&output.stdout),
3421 String::from_utf8_lossy(&output.stderr)
3422 );
3423 }
3424
3425 #[test]
3426 fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
3427 let temp = std::env::temp_dir();
3428 let audit_path = temp.join("fallow-audit-base-123-456");
3429 let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
3430 let canonical_audit_path = temp
3431 .canonicalize()
3432 .unwrap_or_else(|_| temp.clone())
3433 .join("fallow-audit-base-456-789");
3434 let unrelated_temp = temp.join("other-worktree");
3435 let output = format!(
3436 "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
3437 audit_path.display(),
3438 unrelated_temp.display(),
3439 reusable_path.display()
3440 );
3441
3442 assert_eq!(
3443 parse_worktree_list(&output),
3444 vec![audit_path, reusable_path.clone()]
3445 );
3446 assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
3447 assert!(is_reusable_audit_worktree_path(&reusable_path));
3448 assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
3449 assert_eq!(
3450 audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
3451 None
3452 );
3453 assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
3454 }
3455
3456 fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
3460 let root = parent.join(name);
3461 fs::create_dir_all(&root).expect("repo root should be created");
3462 fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
3463 git(&root, &["init", "-b", "main"]);
3464 git(&root, &["add", "."]);
3465 git(
3466 &root,
3467 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3468 );
3469 root
3470 }
3471
3472 fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3473 list_audit_worktrees(repo_root)
3474 .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
3475 }
3476
3477 #[test]
3478 fn worktree_cleanup_guard_runs_on_drop() {
3479 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3480 let repo = init_throwaway_repo(tmp.path(), "repo");
3481 let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
3482
3483 git(
3484 &repo,
3485 &[
3486 "worktree",
3487 "add",
3488 "--detach",
3489 "--quiet",
3490 worktree_path.to_str().expect("path is utf-8"),
3491 "HEAD",
3492 ],
3493 );
3494 assert!(worktree_path.is_dir());
3495 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3496
3497 {
3498 let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3499 }
3500
3501 assert!(
3502 !worktree_path.exists(),
3503 "guard Drop should remove the worktree directory",
3504 );
3505 assert!(
3506 !worktree_is_registered_with_git(&repo, &worktree_path),
3507 "guard Drop should remove the git worktree registration",
3508 );
3509 }
3510
3511 #[test]
3512 fn worktree_cleanup_guard_defused_skips_drop() {
3513 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3514 let repo = init_throwaway_repo(tmp.path(), "repo");
3515 let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
3516
3517 git(
3518 &repo,
3519 &[
3520 "worktree",
3521 "add",
3522 "--detach",
3523 "--quiet",
3524 worktree_path.to_str().expect("path is utf-8"),
3525 "HEAD",
3526 ],
3527 );
3528 assert!(worktree_path.is_dir());
3529
3530 {
3531 let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3532 guard.defuse();
3533 guard.defuse();
3534 }
3535
3536 assert!(
3537 worktree_path.is_dir(),
3538 "defused guard must not remove the worktree on drop",
3539 );
3540 assert!(
3541 worktree_is_registered_with_git(&repo, &worktree_path),
3542 "defused guard must not unregister the worktree from git",
3543 );
3544
3545 remove_audit_worktree(&repo, &worktree_path);
3546 let _ = fs::remove_dir_all(&worktree_path);
3547 }
3548
3549 #[test]
3550 fn audit_orphan_sweep_removes_dead_pid_worktree() {
3551 const DEAD_PID: u32 = 99_999_999;
3552 assert!(!process_is_alive(DEAD_PID));
3553
3554 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3555 let repo = init_throwaway_repo(tmp.path(), "repo");
3556
3557 let worktree_path = std::env::temp_dir().join(format!(
3558 "fallow-audit-base-{}-{}",
3559 DEAD_PID,
3560 std::time::SystemTime::now()
3561 .duration_since(std::time::UNIX_EPOCH)
3562 .expect("clock should be after epoch")
3563 .as_nanos()
3564 ));
3565 git(
3566 &repo,
3567 &[
3568 "worktree",
3569 "add",
3570 "--detach",
3571 "--quiet",
3572 worktree_path.to_str().expect("path is utf-8"),
3573 "HEAD",
3574 ],
3575 );
3576 assert!(worktree_path.is_dir());
3577 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3578
3579 sweep_orphan_audit_worktrees(&repo);
3580
3581 assert!(
3582 !worktree_path.exists(),
3583 "sweep should remove worktree owned by a dead PID",
3584 );
3585 assert!(
3586 !worktree_is_registered_with_git(&repo, &worktree_path),
3587 "sweep should unregister worktree owned by a dead PID",
3588 );
3589 }
3590
3591 #[test]
3592 fn audit_orphan_sweep_keeps_live_pid_worktree() {
3593 let live_pid = std::process::id();
3594 assert!(process_is_alive(live_pid));
3595
3596 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3597 let repo = init_throwaway_repo(tmp.path(), "repo");
3598
3599 let worktree_path = std::env::temp_dir().join(format!(
3600 "fallow-audit-base-{}-{}",
3601 live_pid,
3602 std::time::SystemTime::now()
3603 .duration_since(std::time::UNIX_EPOCH)
3604 .expect("clock should be after epoch")
3605 .as_nanos()
3606 ));
3607 git(
3608 &repo,
3609 &[
3610 "worktree",
3611 "add",
3612 "--detach",
3613 "--quiet",
3614 worktree_path.to_str().expect("path is utf-8"),
3615 "HEAD",
3616 ],
3617 );
3618
3619 sweep_orphan_audit_worktrees(&repo);
3620
3621 assert!(
3622 worktree_path.is_dir(),
3623 "sweep must not remove worktree owned by a live PID",
3624 );
3625 assert!(
3626 worktree_is_registered_with_git(&repo, &worktree_path),
3627 "sweep must not unregister worktree owned by a live PID",
3628 );
3629
3630 remove_audit_worktree(&repo, &worktree_path);
3631 let _ = fs::remove_dir_all(&worktree_path);
3632 }
3633
3634 fn make_reusable_path(label: &str) -> PathBuf {
3638 let nanos = std::time::SystemTime::now()
3639 .duration_since(std::time::UNIX_EPOCH)
3640 .expect("clock should be after epoch")
3641 .as_nanos();
3642 std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
3643 }
3644
3645 fn register_reusable_worktree(repo: &Path, path: &Path) {
3649 git(
3650 repo,
3651 &[
3652 "worktree",
3653 "add",
3654 "--detach",
3655 "--quiet",
3656 path.to_str().expect("path is utf-8"),
3657 "HEAD",
3658 ],
3659 );
3660 }
3661
3662 fn write_sidecar_with_age(path: &Path, age: Duration) {
3663 let sidecar = reusable_worktree_last_used_path(path);
3664 let file = std::fs::OpenOptions::new()
3665 .create(true)
3666 .truncate(false)
3667 .write(true)
3668 .open(&sidecar)
3669 .expect("sidecar should open");
3670 let when = SystemTime::now()
3671 .checked_sub(age)
3672 .expect("backdated time should fit in SystemTime");
3673 file.set_modified(when)
3674 .expect("set_modified should succeed");
3675 }
3676
3677 fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
3680 remove_audit_worktree(repo, path);
3681 let _ = fs::remove_dir_all(path);
3682 let _ = fs::remove_file(reusable_worktree_last_used_path(path));
3683 let _ = fs::remove_file(reusable_worktree_lock_path(path));
3684 }
3685
3686 #[test]
3687 fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
3688 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3689 let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
3690 let worktree_path = make_reusable_path("gc-remove");
3691 register_reusable_worktree(&repo, &worktree_path);
3692 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3693
3694 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3695
3696 assert!(
3697 !worktree_path.exists(),
3698 "sweep should remove worktree dir whose sidecar is older than the threshold",
3699 );
3700 assert!(
3701 !worktree_is_registered_with_git(&repo, &worktree_path),
3702 "sweep should unregister the worktree from git",
3703 );
3704 assert!(
3705 !reusable_worktree_last_used_path(&worktree_path).exists(),
3706 "sweep should remove the sidecar `.last-used` file alongside the worktree",
3707 );
3708 cleanup_reusable_worktree(&repo, &worktree_path);
3709 }
3710
3711 #[test]
3712 fn reusable_cache_gc_keeps_fresh_entry() {
3713 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3714 let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
3715 let worktree_path = make_reusable_path("gc-keep");
3716 register_reusable_worktree(&repo, &worktree_path);
3717 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3718
3719 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3720
3721 assert!(
3722 worktree_path.is_dir(),
3723 "sweep must not remove a worktree whose sidecar is fresher than the threshold",
3724 );
3725 assert!(
3726 worktree_is_registered_with_git(&repo, &worktree_path),
3727 "sweep must not unregister a fresh worktree",
3728 );
3729 cleanup_reusable_worktree(&repo, &worktree_path);
3730 }
3731
3732 #[test]
3733 fn reusable_cache_gc_skips_locked_entry() {
3734 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3735 let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
3736 let worktree_path = make_reusable_path("gc-locked");
3737 register_reusable_worktree(&repo, &worktree_path);
3738 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3739
3740 let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
3741 .expect("test should acquire the lock first");
3742
3743 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3744
3745 assert!(
3746 worktree_path.is_dir(),
3747 "sweep must skip a locked entry even when its sidecar is stale",
3748 );
3749 assert!(
3750 worktree_is_registered_with_git(&repo, &worktree_path),
3751 "sweep must not unregister a locked entry",
3752 );
3753 drop(lock);
3754 cleanup_reusable_worktree(&repo, &worktree_path);
3755 }
3756
3757 #[test]
3758 fn reusable_cache_gc_grace_when_sidecar_absent() {
3759 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3760 let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
3761 let worktree_path = make_reusable_path("gc-grace");
3762 register_reusable_worktree(&repo, &worktree_path);
3763 let sidecar = reusable_worktree_last_used_path(&worktree_path);
3764 assert!(
3765 !sidecar.exists(),
3766 "test pre-condition: sidecar should not exist",
3767 );
3768
3769 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3770
3771 assert!(
3772 worktree_path.is_dir(),
3773 "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
3774 );
3775 assert!(
3776 sidecar.exists(),
3777 "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
3778 );
3779 let mtime = std::fs::metadata(&sidecar)
3780 .and_then(|m| m.modified())
3781 .expect("seeded sidecar should have a readable mtime");
3782 let age = SystemTime::now()
3783 .duration_since(mtime)
3784 .unwrap_or(Duration::ZERO);
3785 assert!(
3786 age < Duration::from_mins(1),
3787 "seeded sidecar mtime should be near `now()`, got age {age:?}",
3788 );
3789 cleanup_reusable_worktree(&repo, &worktree_path);
3790 }
3791
3792 #[test]
3793 fn reusable_cache_gc_preserves_lock_file_after_removal() {
3794 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3795 let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
3796 let worktree_path = make_reusable_path("gc-lockfile");
3797 register_reusable_worktree(&repo, &worktree_path);
3798 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3799 let lock_path = reusable_worktree_lock_path(&worktree_path);
3800 drop(
3801 ReusableWorktreeLock::try_acquire(&worktree_path)
3802 .expect("test should acquire the lock"),
3803 );
3804 assert!(
3805 lock_path.exists(),
3806 "test pre-condition: lock file should exist before sweep",
3807 );
3808
3809 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3810
3811 assert!(
3812 !worktree_path.exists(),
3813 "sweep should still remove the worktree directory",
3814 );
3815 assert!(
3816 lock_path.exists(),
3817 "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
3818 );
3819 let _ = fs::remove_file(&lock_path);
3820 cleanup_reusable_worktree(&repo, &worktree_path);
3821 }
3822
3823 #[test]
3824 fn reuse_or_create_stamps_sidecar_on_fresh_create() {
3825 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3826 let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
3827 let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
3828
3829 let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
3830 .expect("fresh reuse_or_create should succeed on a clean repo");
3831 let cache_path = worktree.path().to_path_buf();
3832 let sidecar = reusable_worktree_last_used_path(&cache_path);
3833
3834 assert!(
3835 sidecar.exists(),
3836 "fresh-create must write the sidecar so age is measured from now",
3837 );
3838 let initial_age = std::fs::metadata(&sidecar)
3839 .and_then(|m| m.modified())
3840 .ok()
3841 .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
3842 .expect("sidecar mtime should be readable and not in the future");
3843 assert!(
3844 initial_age < Duration::from_mins(1),
3845 "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
3846 );
3847
3848 drop(worktree);
3849 cleanup_reusable_worktree(&repo, &cache_path);
3850 }
3851
3852 #[test]
3853 fn days_to_duration_zero_disables() {
3854 assert!(days_to_duration(0).is_none());
3855 assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
3856 assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
3857 }
3858
3859 #[test]
3860 fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
3861 let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
3862 let sidecar = reusable_worktree_last_used_path(&cache_dir);
3863 assert_eq!(sidecar.parent(), cache_dir.parent());
3864 assert_eq!(
3865 sidecar.file_name().and_then(|s| s.to_str()),
3866 Some("fallow-audit-base-cache-abcd-1234.last-used"),
3867 );
3868 }
3869
3870 #[test]
3871 fn touch_last_used_creates_sidecar_if_missing() {
3872 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3873 let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
3874 fs::create_dir(&cache_dir).expect("cache dir should be created");
3875 let sidecar = reusable_worktree_last_used_path(&cache_dir);
3876 assert!(!sidecar.exists(), "sidecar should not exist before touch");
3877
3878 touch_last_used(&cache_dir);
3879
3880 assert!(sidecar.exists(), "touch should create the sidecar");
3881 let mtime = fs::metadata(&sidecar)
3882 .and_then(|m| m.modified())
3883 .expect("sidecar should have an mtime");
3884 let age = SystemTime::now()
3885 .duration_since(mtime)
3886 .unwrap_or(Duration::ZERO);
3887 assert!(
3888 age < Duration::from_mins(1),
3889 "touched sidecar should be near `now()`",
3890 );
3891 }
3892
3893 #[test]
3894 fn reusable_worktree_lock_excludes_concurrent_acquires() {
3895 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3896 let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
3897 let lock_path = reusable_worktree_lock_path(&reusable);
3898
3899 let first = ReusableWorktreeLock::try_acquire(&reusable)
3900 .expect("first acquire on a fresh path should succeed");
3901 assert!(
3902 ReusableWorktreeLock::try_acquire(&reusable).is_none(),
3903 "second acquire must fail while the first is held",
3904 );
3905 drop(first);
3906 assert!(
3907 lock_path.exists(),
3908 "lock file must persist after drop (only the kernel lock is released)",
3909 );
3910 }
3911
3912 #[test]
3913 fn base_analysis_root_preserves_repo_subdirectory_roots() {
3914 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3915 let repo = tmp.path().join("repo");
3916 let app_root = repo.join("apps/mobile");
3917 let base_worktree = tmp.path().join("base-worktree");
3918 fs::create_dir_all(&app_root).expect("app root should be created");
3919 fs::create_dir_all(&base_worktree).expect("base worktree should be created");
3920 git(&repo, &["init", "-b", "main"]);
3921
3922 assert_eq!(
3923 base_analysis_root(&app_root, &base_worktree),
3924 base_worktree.join("apps/mobile")
3925 );
3926 }
3927
3928 #[test]
3929 fn audit_base_worktree_reuses_current_node_modules_context() {
3930 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3931 let root = tmp.path();
3932 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3933 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
3934 .expect("gitignore should be written");
3935 fs::write(
3936 root.join("package.json"),
3937 r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
3938 )
3939 .expect("package.json should be written");
3940 fs::write(
3941 root.join("tsconfig.json"),
3942 r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
3943 )
3944 .expect("tsconfig should be written");
3945 fs::write(
3946 root.join("src/index.ts"),
3947 "import { used } from '@/feature';\nconsole.log(used);\n",
3948 )
3949 .expect("index should be written");
3950 fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
3951 .expect("feature should be written");
3952
3953 git(root, &["init", "-b", "main"]);
3954 git(root, &["add", "."]);
3955 git(
3956 root,
3957 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3958 );
3959
3960 let rn_config = root.join("node_modules/@react-native/typescript-config");
3961 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
3962 fs::write(
3963 rn_config.join("tsconfig.json"),
3964 r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
3965 )
3966 .expect("node_modules tsconfig should be written");
3967
3968 let worktree =
3969 BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
3970 assert!(
3971 worktree.path().join("node_modules").is_dir(),
3972 "base worktree should reuse ignored node_modules from the current checkout"
3973 );
3974 assert!(
3975 worktree
3976 .path()
3977 .join("node_modules/@react-native/typescript-config/tsconfig.json")
3978 .is_file(),
3979 "base worktree should preserve tsconfig extends targets installed in node_modules"
3980 );
3981 }
3982
3983 #[test]
3993 fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
3994 let host = tempfile::TempDir::new().expect("host tempdir should be created");
3995 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3996
3997 let dot_nuxt = host.path().join(".nuxt");
3998 fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
3999 fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
4000 .expect(".nuxt/tsconfig.json should be written");
4001 fs::write(
4002 dot_nuxt.join("tsconfig.app.json"),
4003 r#"{"compilerOptions":{}}"#,
4004 )
4005 .expect(".nuxt/tsconfig.app.json should be written");
4006
4007 materialize_base_dependency_context(host.path(), worktree.path());
4008
4009 let mirrored = worktree.path().join(".nuxt");
4010 assert!(
4011 mirrored.is_dir(),
4012 "base worktree should reuse the ignored .nuxt dir from the host checkout"
4013 );
4014 let link_meta = fs::symlink_metadata(&mirrored)
4015 .expect(".nuxt entry should exist as a symlink in the worktree");
4016 assert!(
4017 link_meta.file_type().is_symlink(),
4018 "base worktree's .nuxt should be a symlink to the host checkout"
4019 );
4020 assert!(
4021 mirrored.join("tsconfig.json").is_file(),
4022 "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
4023 prerequisite check stays quiet"
4024 );
4025 assert!(
4026 mirrored.join("tsconfig.app.json").is_file(),
4027 "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
4028 resolve without falling back to resolver-less resolution"
4029 );
4030 }
4031
4032 #[test]
4037 fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
4038 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4039 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4040
4041 let dot_astro = host.path().join(".astro");
4042 fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
4043 fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
4044 .expect(".astro/types.d.ts should be written");
4045
4046 materialize_base_dependency_context(host.path(), worktree.path());
4047
4048 let mirrored = worktree.path().join(".astro");
4049 assert!(
4050 mirrored.is_dir(),
4051 "base worktree should reuse the ignored .astro dir from the host checkout"
4052 );
4053 assert!(
4054 mirrored.join("types.d.ts").is_file(),
4055 "base worktree should expose generated Astro types so the Astro meta-framework \
4056 prerequisite check stays quiet"
4057 );
4058 }
4059
4060 #[test]
4067 fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
4068 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4069 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4070
4071 materialize_base_dependency_context(host.path(), worktree.path());
4072
4073 assert!(
4074 !worktree.path().join(".nuxt").exists(),
4075 "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
4076 );
4077 assert!(
4078 !worktree.path().join(".astro").exists(),
4079 "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
4080 );
4081 assert!(
4082 !worktree.path().join("node_modules").exists(),
4083 "base worktree should not fabricate a node_modules symlink when the host has none"
4084 );
4085 }
4086
4087 #[test]
4091 fn materialize_base_dependency_context_handles_each_dir_independently() {
4092 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4093 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4094
4095 fs::create_dir_all(host.path().join("node_modules"))
4096 .expect("host node_modules should be created");
4097
4098 materialize_base_dependency_context(host.path(), worktree.path());
4099
4100 assert!(
4101 worktree.path().join("node_modules").is_dir(),
4102 "node_modules should still be symlinked even when host has no .nuxt or .astro"
4103 );
4104 assert!(
4105 !worktree.path().join(".nuxt").exists(),
4106 "missing host .nuxt should leave the worktree slot empty"
4107 );
4108 }
4109
4110 #[test]
4117 fn materialize_base_dependency_context_preserves_real_worktree_dir() {
4118 let host = tempfile::TempDir::new().expect("host tempdir should be created");
4119 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4120
4121 let host_nuxt = host.path().join(".nuxt");
4122 fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
4123 fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
4124 .expect("host .nuxt/tsconfig.json should be written");
4125
4126 let worktree_nuxt = worktree.path().join(".nuxt");
4127 fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
4128 fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
4129 .expect("worktree .nuxt/tsconfig.json should be written");
4130
4131 materialize_base_dependency_context(host.path(), worktree.path());
4132
4133 let link_meta = fs::symlink_metadata(&worktree_nuxt)
4134 .expect(".nuxt entry should still exist in the worktree");
4135 assert!(
4136 !link_meta.file_type().is_symlink(),
4137 "a real base-tracked .nuxt dir must not be replaced by a host symlink"
4138 );
4139 let contents =
4140 fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
4141 assert!(
4142 contents.contains("base"),
4143 "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
4144 );
4145 }
4146
4147 #[test]
4148 fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
4149 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4150 let root = tmp.path();
4151 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
4152 .expect("gitignore should be written");
4153 fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
4154 .expect("package.json should be written");
4155
4156 git(root, &["init", "-b", "main"]);
4157 git(root, &["add", "."]);
4158 git(
4159 root,
4160 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4161 );
4162
4163 let rn_config = root.join("node_modules/@react-native/typescript-config");
4164 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
4165 fs::write(rn_config.join("tsconfig.json"), "{}")
4166 .expect("node_modules tsconfig should be written");
4167
4168 let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
4169 let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
4170 .expect("persistent base worktree should be created");
4171 let worktree_path = first.path().to_path_buf();
4172 assert!(
4173 worktree_path.join("node_modules").is_dir(),
4174 "initial persistent worktree should receive node_modules context"
4175 );
4176 remove_node_modules_context(&worktree_path);
4177 assert!(
4178 !worktree_path.join("node_modules").exists(),
4179 "test setup should remove the dependency context from the reusable worktree"
4180 );
4181 drop(first);
4182
4183 let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
4184 .expect("ready persistent base worktree should be reused");
4185 assert_eq!(reused.path(), worktree_path.as_path());
4186 assert!(
4187 reused.path().join("node_modules").is_dir(),
4188 "ready persistent worktree should refresh missing node_modules context"
4189 );
4190
4191 remove_audit_worktree(root, reused.path());
4192 let _ = fs::remove_dir_all(reused.path());
4193 }
4194
4195 fn remove_node_modules_context(worktree_path: &Path) {
4196 let path = worktree_path.join("node_modules");
4197 let Ok(metadata) = fs::symlink_metadata(&path) else {
4198 return;
4199 };
4200 if metadata.file_type().is_symlink() {
4201 #[cfg(unix)]
4202 let _ = fs::remove_file(path);
4203 #[cfg(windows)]
4204 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
4205 } else {
4206 let _ = fs::remove_dir_all(path);
4207 }
4208 }
4209
4210 #[test]
4211 fn audit_base_snapshot_cache_payload_roundtrips_sets() {
4212 let key = AuditBaseSnapshotCacheKey {
4213 hash: 42,
4214 base_sha: "abc123".to_string(),
4215 };
4216 let snapshot = AuditKeySnapshot {
4217 dead_code: ["dead:a".to_string(), "dead:b".to_string()]
4218 .into_iter()
4219 .collect(),
4220 health: std::iter::once("health:a".to_string()).collect(),
4221 dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
4222 .into_iter()
4223 .collect(),
4224 };
4225
4226 let cached = cached_from_snapshot(&key, &snapshot);
4227 assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
4228 assert_eq!(cached.key_hash, key.hash);
4229 assert_eq!(cached.base_sha, key.base_sha);
4230 assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
4231
4232 let decoded = snapshot_from_cached(cached);
4233 assert_eq!(decoded.dead_code, snapshot.dead_code);
4234 assert_eq!(decoded.health, snapshot.health);
4235 assert_eq!(decoded.dupes, snapshot.dupes);
4236 }
4237
4238 #[test]
4239 fn audit_base_snapshot_cache_key_includes_extended_config() {
4240 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4241 let root = tmp.path();
4242 fs::write(
4243 root.join(".fallowrc.json"),
4244 r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
4245 )
4246 .expect("config should be written");
4247 fs::write(
4248 root.join("base.json"),
4249 r#"{"rules":{"unused-exports":"off"}}"#,
4250 )
4251 .expect("base config should be written");
4252
4253 let config_path = None;
4254 let opts = AuditOptions {
4255 root,
4256 config_path: &config_path,
4257 output: OutputFormat::Json,
4258 no_cache: false,
4259 threads: 1,
4260 quiet: true,
4261 changed_since: Some("HEAD"),
4262 production: false,
4263 production_dead_code: None,
4264 production_health: None,
4265 production_dupes: None,
4266 workspace: None,
4267 changed_workspaces: None,
4268 explain: false,
4269 explain_skipped: false,
4270 performance: false,
4271 group_by: None,
4272 dead_code_baseline: None,
4273 health_baseline: None,
4274 dupes_baseline: None,
4275 max_crap: None,
4276 coverage: None,
4277 coverage_root: None,
4278 gate: AuditGate::NewOnly,
4279 include_entry_exports: false,
4280 runtime_coverage: None,
4281 min_invocations_hot: 100,
4282 };
4283
4284 let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
4285 fs::write(
4286 root.join("base.json"),
4287 r#"{"rules":{"unused-exports":"error"}}"#,
4288 )
4289 .expect("base config should be updated");
4290 let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
4291
4292 assert_ne!(
4293 first["resolved_hash"], second["resolved_hash"],
4294 "extended config changes must invalidate cached base snapshots"
4295 );
4296 }
4297
4298 #[test]
4299 fn audit_gate_all_skips_base_snapshot() {
4300 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4301 let root = tmp.path();
4302 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4303 fs::write(
4304 root.join("package.json"),
4305 r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
4306 )
4307 .expect("package.json should be written");
4308 fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
4309 .expect("index should be written");
4310
4311 git(root, &["init", "-b", "main"]);
4312 git(root, &["add", "."]);
4313 git(
4314 root,
4315 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4316 );
4317 fs::write(
4318 root.join("src/index.ts"),
4319 "export const legacy = 1;\nexport const changed = 2;\n",
4320 )
4321 .expect("changed module should be written");
4322
4323 let config_path = None;
4324 let opts = AuditOptions {
4325 root,
4326 config_path: &config_path,
4327 output: OutputFormat::Json,
4328 no_cache: true,
4329 threads: 1,
4330 quiet: true,
4331 changed_since: Some("HEAD"),
4332 production: false,
4333 production_dead_code: None,
4334 production_health: None,
4335 production_dupes: None,
4336 workspace: None,
4337 changed_workspaces: None,
4338 explain: false,
4339 explain_skipped: false,
4340 performance: false,
4341 group_by: None,
4342 dead_code_baseline: None,
4343 health_baseline: None,
4344 dupes_baseline: None,
4345 max_crap: None,
4346 coverage: None,
4347 coverage_root: None,
4348 gate: AuditGate::All,
4349 include_entry_exports: false,
4350 runtime_coverage: None,
4351 min_invocations_hot: 100,
4352 };
4353
4354 let result = execute_audit(&opts).expect("audit should execute");
4355 assert!(result.base_snapshot.is_none());
4356 assert_eq!(result.attribution.gate, AuditGate::All);
4357 assert_eq!(result.attribution.dead_code_introduced, 0);
4358 assert_eq!(result.attribution.dead_code_inherited, 0);
4359 }
4360
4361 #[test]
4362 fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
4363 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4364 let root = tmp.path();
4365 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4366 fs::write(
4367 root.join("package.json"),
4368 r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
4369 )
4370 .expect("package.json should be written");
4371 fs::write(
4372 root.join(".fallowrc.json"),
4373 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
4374 )
4375 .expect("config should be written");
4376 let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
4377 fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
4378 fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
4379 fs::write(root.join("README.md"), "before\n").expect("readme should be written");
4380
4381 git(root, &["init", "-b", "main"]);
4382 git(root, &["add", "."]);
4383 git(
4384 root,
4385 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4386 );
4387 fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
4388 fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
4389 .expect("cache dir should be created");
4390 fs::write(
4391 root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
4392 b"cache",
4393 )
4394 .expect("cache artifact should be written");
4395
4396 let before_worktrees = audit_worktree_names(root);
4397
4398 let config_path = None;
4399 let opts = AuditOptions {
4400 root,
4401 config_path: &config_path,
4402 output: OutputFormat::Json,
4403 no_cache: true,
4404 threads: 1,
4405 quiet: true,
4406 changed_since: Some("HEAD"),
4407 production: false,
4408 production_dead_code: None,
4409 production_health: None,
4410 production_dupes: None,
4411 workspace: None,
4412 changed_workspaces: None,
4413 explain: false,
4414 explain_skipped: false,
4415 performance: true,
4416 group_by: None,
4417 dead_code_baseline: None,
4418 health_baseline: None,
4419 dupes_baseline: None,
4420 max_crap: None,
4421 coverage: None,
4422 coverage_root: None,
4423 gate: AuditGate::NewOnly,
4424 include_entry_exports: false,
4425 runtime_coverage: None,
4426 min_invocations_hot: 100,
4427 };
4428
4429 let result = execute_audit(&opts).expect("audit should execute");
4430 assert_eq!(result.verdict, AuditVerdict::Pass);
4431 assert_eq!(result.changed_files_count, 2);
4432 assert!(result.base_snapshot_skipped);
4433 assert!(result.base_snapshot.is_some());
4434
4435 let after_worktrees = audit_worktree_names(root);
4436 assert_eq!(
4437 before_worktrees, after_worktrees,
4438 "base snapshot skip must not create a temporary base worktree"
4439 );
4440 }
4441
4442 fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
4443 let mut names: Vec<String> = list_audit_worktrees(repo_root)
4444 .unwrap_or_default()
4445 .into_iter()
4446 .filter_map(|path| {
4447 path.file_name()
4448 .and_then(|name| name.to_str())
4449 .map(str::to_owned)
4450 })
4451 .collect();
4452 names.sort();
4453 names
4454 }
4455
4456 #[test]
4457 fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
4458 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4459 let root = tmp.path();
4460 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4461 fs::write(
4462 root.join("package.json"),
4463 r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
4464 )
4465 .expect("package.json should be written");
4466 fs::write(
4467 root.join("src/index.ts"),
4468 "import { used } from './used';\nused();\n",
4469 )
4470 .expect("index should be written");
4471 fs::write(
4472 root.join("src/used.ts"),
4473 "export function used() {\n return 1;\n}\n",
4474 )
4475 .expect("used module should be written");
4476
4477 git(root, &["init", "-b", "main"]);
4478 git(root, &["add", "."]);
4479 git(
4480 root,
4481 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4482 );
4483 fs::write(
4484 root.join("src/used.ts"),
4485 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
4486 )
4487 .expect("changed module should be written");
4488
4489 let config_path = None;
4490 let opts = AuditOptions {
4491 root,
4492 config_path: &config_path,
4493 output: OutputFormat::Json,
4494 no_cache: true,
4495 threads: 1,
4496 quiet: true,
4497 changed_since: Some("HEAD"),
4498 production: false,
4499 production_dead_code: None,
4500 production_health: None,
4501 production_dupes: None,
4502 workspace: None,
4503 changed_workspaces: None,
4504 explain: false,
4505 explain_skipped: false,
4506 performance: true,
4507 group_by: None,
4508 dead_code_baseline: None,
4509 health_baseline: None,
4510 dupes_baseline: None,
4511 max_crap: None,
4512 coverage: None,
4513 coverage_root: None,
4514 gate: AuditGate::NewOnly,
4515 include_entry_exports: false,
4516 runtime_coverage: None,
4517 min_invocations_hot: 100,
4518 };
4519
4520 let result = execute_audit(&opts).expect("audit should execute");
4521 let health = result.health.expect("health should run for changed files");
4522 let timings = health.timings.expect("performance timings should be kept");
4523 assert!(timings.discover_ms.abs() < f64::EPSILON);
4524 assert!(timings.parse_ms.abs() < f64::EPSILON);
4525 assert!(
4526 result.dupes.is_some(),
4527 "dupes should run when changed files exist"
4528 );
4529 }
4530
4531 #[test]
4532 fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
4533 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4534 let root = tmp.path();
4535 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4536 fs::write(
4537 root.join("package.json"),
4538 r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
4539 )
4540 .expect("package.json should be written");
4541 fs::write(
4542 root.join("src/index.ts"),
4543 "import { used } from './used';\nused();\n",
4544 )
4545 .expect("index should be written");
4546 fs::write(
4547 root.join("src/used.ts"),
4548 "export function used() {\n return 1;\n}\n",
4549 )
4550 .expect("used module should be written");
4551
4552 git(root, &["init", "-b", "main"]);
4553 git(root, &["add", "."]);
4554 git(
4555 root,
4556 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4557 );
4558 fs::write(
4559 root.join("src/used.ts"),
4560 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
4561 )
4562 .expect("changed module should be written");
4563
4564 let config_path = None;
4565 let opts = AuditOptions {
4566 root,
4567 config_path: &config_path,
4568 output: OutputFormat::Json,
4569 no_cache: true,
4570 threads: 1,
4571 quiet: true,
4572 changed_since: Some("HEAD"),
4573 production: false,
4574 production_dead_code: Some(true),
4575 production_health: Some(false),
4576 production_dupes: Some(false),
4577 workspace: None,
4578 changed_workspaces: None,
4579 explain: false,
4580 explain_skipped: false,
4581 performance: true,
4582 group_by: None,
4583 dead_code_baseline: None,
4584 health_baseline: None,
4585 dupes_baseline: None,
4586 max_crap: None,
4587 coverage: None,
4588 coverage_root: None,
4589 gate: AuditGate::NewOnly,
4590 include_entry_exports: false,
4591 runtime_coverage: None,
4592 min_invocations_hot: 100,
4593 };
4594
4595 let result = execute_audit(&opts).expect("audit should execute");
4596 assert!(result.dupes.is_some(), "dupes should still run");
4597 }
4598
4599 #[cfg(unix)]
4600 #[test]
4601 fn remap_focus_files_does_not_canonicalize_through_symlinks() {
4602 let tmp = tempfile::TempDir::new().expect("temp dir");
4603 let real = tmp.path().join("real");
4604 let link = tmp.path().join("link");
4605 fs::create_dir_all(&real).expect("real dir");
4606 std::os::unix::fs::symlink(&real, &link).expect("symlink");
4607 let canonical = link.canonicalize().expect("canonicalize symlink");
4608 assert_ne!(link, canonical, "symlink should not equal its target");
4609
4610 let from_root = PathBuf::from("/repo");
4611 let mut focus = FxHashSet::default();
4612 focus.insert(from_root.join("src/foo.ts"));
4613
4614 let remapped = remap_focus_files(&focus, &from_root, &link)
4615 .expect("remap should succeed for in-prefix files");
4616
4617 let expected = link.join("src/foo.ts");
4618 assert!(
4619 remapped.contains(&expected),
4620 "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
4621 );
4622 }
4623
4624 #[test]
4625 fn remap_focus_files_skips_paths_outside_from_root() {
4626 let from_root = PathBuf::from("/repo/apps/web");
4627 let to_root = PathBuf::from("/wt/apps/web");
4628 let mut focus = FxHashSet::default();
4629 focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
4630 focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
4631
4632 let remapped =
4633 remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
4634
4635 assert_eq!(remapped.len(), 1);
4636 assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
4637 }
4638
4639 #[test]
4640 fn remap_focus_files_returns_none_when_no_paths_map() {
4641 let from_root = PathBuf::from("/repo/apps/web");
4642 let to_root = PathBuf::from("/wt/apps/web");
4643 let mut focus = FxHashSet::default();
4644 focus.insert(PathBuf::from("/elsewhere/foo.ts"));
4645
4646 let remapped = remap_focus_files(&focus, &from_root, &to_root);
4647 assert!(
4648 remapped.is_none(),
4649 "remap should return None when no paths can be mapped, falling caller back to full corpus"
4650 );
4651 }
4652
4653 #[test]
4654 fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
4655 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4656 let root_buf = tmp
4657 .path()
4658 .canonicalize()
4659 .expect("temp root should canonicalize");
4660 let root = root_buf.as_path();
4661 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4662 fs::write(
4663 root.join("package.json"),
4664 r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
4665 )
4666 .expect("package.json should be written");
4667 fs::write(
4668 root.join(".fallowrc.json"),
4669 r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
4670 )
4671 .expect("config should be written");
4672
4673 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";
4674 fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
4675 fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
4676
4677 git(root, &["init", "-b", "main"]);
4678 git(root, &["add", "."]);
4679 git(
4680 root,
4681 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4682 );
4683 fs::write(
4684 root.join("src/changed.ts"),
4685 format!("{dup_block}// touched\n"),
4686 )
4687 .expect("changed file should be modified");
4688 git(root, &["add", "."]);
4689 git(
4690 root,
4691 &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
4692 );
4693
4694 let config_path = None;
4695 let opts = AuditOptions {
4696 root,
4697 config_path: &config_path,
4698 output: OutputFormat::Json,
4699 no_cache: true,
4700 threads: 1,
4701 quiet: true,
4702 changed_since: Some("HEAD~1"),
4703 production: false,
4704 production_dead_code: None,
4705 production_health: None,
4706 production_dupes: None,
4707 workspace: None,
4708 changed_workspaces: None,
4709 explain: false,
4710 explain_skipped: false,
4711 performance: false,
4712 group_by: None,
4713 dead_code_baseline: None,
4714 health_baseline: None,
4715 dupes_baseline: None,
4716 max_crap: None,
4717 coverage: None,
4718 coverage_root: None,
4719 gate: AuditGate::NewOnly,
4720 include_entry_exports: false,
4721 runtime_coverage: None,
4722 min_invocations_hot: 100,
4723 };
4724
4725 let result = execute_audit(&opts).expect("audit should execute");
4726 assert!(
4727 result.base_snapshot_skipped,
4728 "comment-only JS/TS diffs should reuse current keys as the base snapshot"
4729 );
4730 let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
4731 assert!(
4732 !dupes_report.clone_groups.is_empty(),
4733 "current run should detect the pre-existing duplicate"
4734 );
4735 assert_eq!(
4736 result.attribution.duplication_introduced, 0,
4737 "pre-existing duplicate must not be classified as introduced; \
4738 attribution = {:?}",
4739 result.attribution
4740 );
4741 assert!(
4742 result.attribution.duplication_inherited > 0,
4743 "pre-existing duplicate must be classified as inherited; \
4744 attribution = {:?}",
4745 result.attribution
4746 );
4747 }
4748
4749 #[test]
4750 fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
4751 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4752 let root = tmp.path();
4753 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4754 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4755 .expect("node_modules config dir should be created");
4756 fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
4757 fs::write(
4758 root.join("package.json"),
4759 r#"{
4760 "name": "audit-react-native-tsconfig-base",
4761 "private": true,
4762 "main": "src/App.tsx",
4763 "dependencies": {
4764 "react-native": "0.80.0"
4765 }
4766 }"#,
4767 )
4768 .expect("package.json should be written");
4769 fs::write(
4770 root.join("tsconfig.json"),
4771 r#"{
4772 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4773 "compilerOptions": {
4774 "baseUrl": ".",
4775 "paths": {
4776 "@/*": ["src/*"]
4777 }
4778 },
4779 "include": ["src/**/*"]
4780 }"#,
4781 )
4782 .expect("tsconfig should be written");
4783 fs::write(
4784 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4785 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4786 )
4787 .expect("react native tsconfig should be written");
4788 fs::write(
4789 root.join("src/App.tsx"),
4790 r#"import { homeTitle } from "@/screens/Home";
4791
4792export function App() {
4793 return homeTitle;
4794}
4795"#,
4796 )
4797 .expect("app should be written");
4798 fs::write(
4799 root.join("src/screens/Home.ts"),
4800 r#"export const homeTitle = "home";
4801"#,
4802 )
4803 .expect("home should be written");
4804
4805 git(root, &["init", "-b", "main"]);
4806 git(root, &["add", "."]);
4807 git(
4808 root,
4809 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4810 );
4811 fs::write(
4812 root.join("src/App.tsx"),
4813 r#"import { homeTitle } from "@/screens/Home";
4814
4815export function App() {
4816 return homeTitle.toUpperCase();
4817}
4818"#,
4819 )
4820 .expect("app should be modified");
4821
4822 let config_path = None;
4823 let opts = AuditOptions {
4824 root,
4825 config_path: &config_path,
4826 output: OutputFormat::Json,
4827 no_cache: true,
4828 threads: 1,
4829 quiet: true,
4830 changed_since: Some("HEAD"),
4831 production: false,
4832 production_dead_code: None,
4833 production_health: None,
4834 production_dupes: None,
4835 workspace: None,
4836 changed_workspaces: None,
4837 explain: false,
4838 explain_skipped: false,
4839 performance: false,
4840 group_by: None,
4841 dead_code_baseline: None,
4842 health_baseline: None,
4843 dupes_baseline: None,
4844 max_crap: None,
4845 coverage: None,
4846 coverage_root: None,
4847 gate: AuditGate::NewOnly,
4848 include_entry_exports: false,
4849 runtime_coverage: None,
4850 min_invocations_hot: 100,
4851 };
4852
4853 let result = execute_audit(&opts).expect("audit should execute");
4854 assert!(
4855 !result.base_snapshot_skipped,
4856 "source diffs should run a real base snapshot"
4857 );
4858 let base = result
4859 .base_snapshot
4860 .as_ref()
4861 .expect("base snapshot should run");
4862 assert!(
4863 !base
4864 .dead_code
4865 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
4866 "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
4867 base.dead_code
4868 );
4869 assert!(
4870 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
4871 "alias target should stay reachable in the base worktree: {:?}",
4872 base.dead_code
4873 );
4874 let check = result.check.as_ref().expect("dead-code audit should run");
4875 assert!(
4876 check.results.unresolved_imports.is_empty(),
4877 "HEAD audit should also resolve @/* aliases: {:?}",
4878 check.results.unresolved_imports
4879 );
4880 }
4881
4882 #[test]
4883 fn audit_base_preserves_subdirectory_root_resolution() {
4884 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4885 let repo = tmp.path().join("repo");
4886 let root = repo.join("apps/mobile");
4887 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4888 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4889 .expect("node_modules config dir should be created");
4890 fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
4891 .expect("gitignore should be written");
4892 fs::write(
4893 root.join("package.json"),
4894 r#"{
4895 "name": "audit-subdir-react-native-tsconfig-base",
4896 "private": true,
4897 "main": "src/App.tsx",
4898 "dependencies": {
4899 "react-native": "0.80.0"
4900 }
4901 }"#,
4902 )
4903 .expect("package.json should be written");
4904 fs::write(
4905 root.join("tsconfig.json"),
4906 r#"{
4907 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4908 "compilerOptions": {
4909 "baseUrl": ".",
4910 "paths": {
4911 "@/*": ["src/*"]
4912 }
4913 },
4914 "include": ["src/**/*"]
4915 }"#,
4916 )
4917 .expect("tsconfig should be written");
4918 fs::write(
4919 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4920 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4921 )
4922 .expect("react native tsconfig should be written");
4923 fs::write(
4924 root.join("src/App.tsx"),
4925 r#"import { homeTitle } from "@/screens/Home";
4926
4927export function App() {
4928 return homeTitle;
4929}
4930"#,
4931 )
4932 .expect("app should be written");
4933 fs::write(
4934 root.join("src/screens/Home.ts"),
4935 r#"export const homeTitle = "home";
4936"#,
4937 )
4938 .expect("home should be written");
4939
4940 git(&repo, &["init", "-b", "main"]);
4941 git(&repo, &["add", "."]);
4942 git(
4943 &repo,
4944 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4945 );
4946 fs::write(
4947 root.join("src/App.tsx"),
4948 r#"import { homeTitle } from "@/screens/Home";
4949
4950export function App() {
4951 return homeTitle.toUpperCase();
4952}
4953"#,
4954 )
4955 .expect("app should be modified");
4956
4957 let config_path = None;
4958 let opts = AuditOptions {
4959 root: &root,
4960 config_path: &config_path,
4961 output: OutputFormat::Json,
4962 no_cache: true,
4963 threads: 1,
4964 quiet: true,
4965 changed_since: Some("HEAD"),
4966 production: false,
4967 production_dead_code: None,
4968 production_health: None,
4969 production_dupes: None,
4970 workspace: None,
4971 changed_workspaces: None,
4972 explain: false,
4973 explain_skipped: false,
4974 performance: false,
4975 group_by: None,
4976 dead_code_baseline: None,
4977 health_baseline: None,
4978 dupes_baseline: None,
4979 max_crap: None,
4980 coverage: None,
4981 coverage_root: None,
4982 gate: AuditGate::NewOnly,
4983 include_entry_exports: false,
4984 runtime_coverage: None,
4985 min_invocations_hot: 100,
4986 };
4987
4988 let result = execute_audit(&opts).expect("audit should execute");
4989 assert!(
4990 !result.base_snapshot_skipped,
4991 "source diffs should run a real base snapshot"
4992 );
4993 let base = result
4994 .base_snapshot
4995 .as_ref()
4996 .expect("base snapshot should run");
4997 assert!(
4998 !base
4999 .dead_code
5000 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
5001 "base audit should analyze from the app subdirectory, not the repo root: {:?}",
5002 base.dead_code
5003 );
5004 assert!(
5005 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
5006 "subdirectory base audit should keep alias targets reachable: {:?}",
5007 base.dead_code
5008 );
5009 }
5010
5011 #[test]
5012 fn audit_base_uses_new_explicit_config_without_hard_failure() {
5013 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5014 let root = tmp.path();
5015 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5016 fs::write(
5017 root.join("package.json"),
5018 r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
5019 )
5020 .expect("package.json should be written");
5021 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5022 .expect("index should be written");
5023
5024 git(root, &["init", "-b", "main"]);
5025 git(root, &["add", "."]);
5026 git(
5027 root,
5028 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5029 );
5030
5031 let explicit_config = root.join(".fallowrc.json");
5032 fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
5033 .expect("new config should be written");
5034 fs::write(root.join("src/index.ts"), "export const used = 2;\n")
5035 .expect("index should be modified");
5036
5037 let config_path = Some(explicit_config);
5038 let opts = AuditOptions {
5039 root,
5040 config_path: &config_path,
5041 output: OutputFormat::Json,
5042 no_cache: true,
5043 threads: 1,
5044 quiet: true,
5045 changed_since: Some("HEAD"),
5046 production: false,
5047 production_dead_code: None,
5048 production_health: None,
5049 production_dupes: None,
5050 workspace: None,
5051 changed_workspaces: None,
5052 explain: false,
5053 explain_skipped: false,
5054 performance: false,
5055 group_by: None,
5056 dead_code_baseline: None,
5057 health_baseline: None,
5058 dupes_baseline: None,
5059 max_crap: None,
5060 coverage: None,
5061 coverage_root: None,
5062 gate: AuditGate::NewOnly,
5063 include_entry_exports: false,
5064 runtime_coverage: None,
5065 min_invocations_hot: 100,
5066 };
5067
5068 let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
5069 assert!(
5070 result.base_snapshot.is_some(),
5071 "base snapshot should use the current explicit config even when the base commit lacks it"
5072 );
5073 }
5074
5075 #[test]
5076 fn audit_base_uses_current_discovered_config_for_attribution() {
5077 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5078 let root = tmp.path();
5079 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5080 fs::write(
5081 root.join("package.json"),
5082 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5083 )
5084 .expect("package.json should be written");
5085 fs::write(
5086 root.join(".fallowrc.json"),
5087 r#"{"rules":{"unused-dependencies":"off"}}"#,
5088 )
5089 .expect("base config should be written");
5090 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5091 .expect("index should be written");
5092
5093 git(root, &["init", "-b", "main"]);
5094 git(root, &["add", "."]);
5095 git(
5096 root,
5097 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5098 );
5099
5100 fs::write(
5101 root.join(".fallowrc.json"),
5102 r#"{"rules":{"unused-dependencies":"error"}}"#,
5103 )
5104 .expect("current config should be written");
5105 fs::write(
5106 root.join("package.json"),
5107 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5108 )
5109 .expect("package.json should be touched");
5110
5111 let config_path = None;
5112 let opts = AuditOptions {
5113 root,
5114 config_path: &config_path,
5115 output: OutputFormat::Json,
5116 no_cache: true,
5117 threads: 1,
5118 quiet: true,
5119 changed_since: Some("HEAD"),
5120 production: false,
5121 production_dead_code: None,
5122 production_health: None,
5123 production_dupes: None,
5124 workspace: None,
5125 changed_workspaces: None,
5126 explain: false,
5127 explain_skipped: false,
5128 performance: false,
5129 group_by: None,
5130 dead_code_baseline: None,
5131 health_baseline: None,
5132 dupes_baseline: None,
5133 max_crap: None,
5134 coverage: None,
5135 coverage_root: None,
5136 gate: AuditGate::NewOnly,
5137 include_entry_exports: false,
5138 runtime_coverage: None,
5139 min_invocations_hot: 100,
5140 };
5141
5142 let result = execute_audit(&opts).expect("audit should execute");
5143 assert_eq!(
5144 result.attribution.dead_code_introduced, 0,
5145 "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
5146 result.attribution
5147 );
5148 assert!(
5149 result.attribution.dead_code_inherited > 0,
5150 "pre-existing changed-file findings should be classified as inherited: {:?}",
5151 result.attribution
5152 );
5153 }
5154
5155 #[test]
5156 fn audit_base_current_config_attribution_survives_cache_hit() {
5157 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5158 let root = tmp.path();
5159 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5160 fs::write(
5161 root.join("package.json"),
5162 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5163 )
5164 .expect("package.json should be written");
5165 fs::write(
5166 root.join(".fallowrc.json"),
5167 r#"{"rules":{"unused-dependencies":"off"}}"#,
5168 )
5169 .expect("base config should be written");
5170 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5171 .expect("index should be written");
5172
5173 git(root, &["init", "-b", "main"]);
5174 git(root, &["add", "."]);
5175 git(
5176 root,
5177 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5178 );
5179
5180 fs::write(
5181 root.join(".fallowrc.json"),
5182 r#"{"rules":{"unused-dependencies":"error"}}"#,
5183 )
5184 .expect("current config should be written");
5185 fs::write(
5186 root.join("package.json"),
5187 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5188 )
5189 .expect("package.json should be touched");
5190
5191 let config_path = None;
5192 let opts = AuditOptions {
5193 root,
5194 config_path: &config_path,
5195 output: OutputFormat::Json,
5196 no_cache: false,
5197 threads: 1,
5198 quiet: true,
5199 changed_since: Some("HEAD"),
5200 production: false,
5201 production_dead_code: None,
5202 production_health: None,
5203 production_dupes: None,
5204 workspace: None,
5205 changed_workspaces: None,
5206 explain: false,
5207 explain_skipped: false,
5208 performance: false,
5209 group_by: None,
5210 dead_code_baseline: None,
5211 health_baseline: None,
5212 dupes_baseline: None,
5213 max_crap: None,
5214 coverage: None,
5215 coverage_root: None,
5216 gate: AuditGate::NewOnly,
5217 include_entry_exports: false,
5218 runtime_coverage: None,
5219 min_invocations_hot: 100,
5220 };
5221
5222 let first = execute_audit(&opts).expect("first audit should execute");
5223 assert_eq!(
5224 first.attribution.dead_code_introduced, 0,
5225 "first audit should classify pre-existing findings as inherited: {:?}",
5226 first.attribution
5227 );
5228
5229 let changed_files =
5230 crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
5231 let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
5232 .expect("cache key should compute")
5233 .expect("cache key should exist");
5234 assert!(
5235 load_cached_base_snapshot(&opts, &key).is_some(),
5236 "first audit should store a reusable base snapshot"
5237 );
5238
5239 let second = execute_audit(&opts).expect("second audit should execute");
5240 assert_eq!(
5241 second.attribution.dead_code_introduced, 0,
5242 "cache hit should keep current-config attribution stable: {:?}",
5243 second.attribution
5244 );
5245 assert!(
5246 second.attribution.dead_code_inherited > 0,
5247 "cache hit should preserve inherited base findings: {:?}",
5248 second.attribution
5249 );
5250 }
5251
5252 #[test]
5253 fn audit_dupes_only_materializes_groups_touching_changed_files() {
5254 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5255 let root_path = tmp
5256 .path()
5257 .canonicalize()
5258 .expect("temp root should canonicalize");
5259 let root = root_path.as_path();
5260 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5261 fs::write(
5262 root.join("package.json"),
5263 r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
5264 )
5265 .expect("package.json should be written");
5266 fs::write(
5267 root.join(".fallowrc.json"),
5268 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
5269 )
5270 .expect("config should be written");
5271
5272 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";
5273 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";
5274 fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
5275 fs::write(root.join("src/focused-copy.ts"), focused_code)
5276 .expect("focused copy should be written");
5277 fs::write(root.join("src/untouched-a.ts"), untouched_code)
5278 .expect("untouched a should be written");
5279 fs::write(root.join("src/untouched-b.ts"), untouched_code)
5280 .expect("untouched b should be written");
5281
5282 git(root, &["init", "-b", "main"]);
5283 git(root, &["add", "."]);
5284 git(
5285 root,
5286 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5287 );
5288 fs::write(
5289 root.join("src/changed.ts"),
5290 format!("{focused_code}export const changedMarker = true;\n"),
5291 )
5292 .expect("changed file should be modified");
5293
5294 let config_path = None;
5295 let opts = AuditOptions {
5296 root,
5297 config_path: &config_path,
5298 output: OutputFormat::Json,
5299 no_cache: true,
5300 threads: 1,
5301 quiet: true,
5302 changed_since: Some("HEAD"),
5303 production: false,
5304 production_dead_code: None,
5305 production_health: None,
5306 production_dupes: None,
5307 workspace: None,
5308 changed_workspaces: None,
5309 explain: false,
5310 explain_skipped: false,
5311 performance: false,
5312 group_by: None,
5313 dead_code_baseline: None,
5314 health_baseline: None,
5315 dupes_baseline: None,
5316 max_crap: None,
5317 coverage: None,
5318 coverage_root: None,
5319 gate: AuditGate::All,
5320 include_entry_exports: false,
5321 runtime_coverage: None,
5322 min_invocations_hot: 100,
5323 };
5324
5325 let result = execute_audit(&opts).expect("audit should execute");
5326 let dupes = result.dupes.expect("dupes should run");
5327 let changed_path = root.join("src/changed.ts");
5328
5329 assert!(
5330 !dupes.report.clone_groups.is_empty(),
5331 "changed file should still match unchanged duplicate code"
5332 );
5333 assert!(dupes.report.clone_groups.iter().all(|group| {
5334 group
5335 .instances
5336 .iter()
5337 .any(|instance| instance.file == changed_path)
5338 }));
5339 }
5340}