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