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