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