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 baseline: opts.dead_code_baseline,
2814 save_baseline: None,
2815 sarif_file: None,
2816 production: opts.production_dead_code.unwrap_or(opts.production),
2817 production_override: opts.production_dead_code,
2818 workspace: opts.workspace,
2819 changed_workspaces: opts.changed_workspaces,
2820 group_by: opts.group_by,
2821 include_dupes: false,
2822 trace_opts: &trace_opts,
2823 explain: opts.explain,
2824 top: None,
2825 file: &[],
2826 include_entry_exports: opts.include_entry_exports,
2827 summary: false,
2828 regression_opts: crate::regression::RegressionOpts {
2829 fail_on_regression: false,
2830 tolerance: crate::regression::Tolerance::Absolute(0),
2831 regression_baseline_file: None,
2832 save_target: crate::regression::SaveRegressionTarget::None,
2833 scoped: true,
2834 quiet: opts.quiet,
2835 output: opts.output,
2836 },
2837 retain_modules_for_health,
2838 defer_performance: false,
2839 }) {
2840 Ok(r) => Ok(Some(r)),
2841 Err(code) => Err(code),
2842 }
2843}
2844
2845fn run_audit_dupes<'a>(
2851 opts: &'a AuditOptions<'a>,
2852 changed_since: Option<&'a str>,
2853 changed_files: Option<&'a FxHashSet<PathBuf>>,
2854 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2855) -> Result<Option<DupesResult>, ExitCode> {
2856 let dupes_cfg = match crate::load_config_for_analysis(
2857 opts.root,
2858 opts.config_path,
2859 opts.output,
2860 opts.no_cache,
2861 opts.threads,
2862 opts.production_dupes
2863 .or_else(|| opts.production.then_some(true)),
2864 opts.quiet,
2865 fallow_config::ProductionAnalysis::Dupes,
2866 ) {
2867 Ok(c) => c.duplicates,
2868 Err(code) => return Err(code),
2869 };
2870 let dupes_opts = DupesOptions {
2871 root: opts.root,
2872 config_path: opts.config_path,
2873 output: opts.output,
2874 no_cache: opts.no_cache,
2875 threads: opts.threads,
2876 quiet: opts.quiet,
2877 mode: Some(DupesMode::from(dupes_cfg.mode)),
2881 min_tokens: Some(dupes_cfg.min_tokens),
2882 min_lines: Some(dupes_cfg.min_lines),
2883 min_occurrences: Some(dupes_cfg.min_occurrences),
2884 threshold: Some(dupes_cfg.threshold),
2885 skip_local: dupes_cfg.skip_local,
2886 cross_language: dupes_cfg.cross_language,
2887 ignore_imports: dupes_cfg.ignore_imports,
2888 top: None,
2889 baseline_path: opts.dupes_baseline,
2890 save_baseline_path: None,
2891 production: opts.production_dupes.unwrap_or(opts.production),
2892 production_override: opts.production_dupes,
2893 trace: None,
2894 changed_since,
2895 changed_files,
2896 workspace: opts.workspace,
2897 changed_workspaces: opts.changed_workspaces,
2898 explain: opts.explain,
2899 explain_skipped: opts.explain_skipped,
2900 summary: false,
2901 group_by: opts.group_by,
2902 performance: false,
2905 };
2906 let dupes_run = if let Some(files) = pre_discovered {
2907 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2908 } else {
2909 crate::dupes::execute_dupes(&dupes_opts)
2910 };
2911 match dupes_run {
2912 Ok(r) => Ok(Some(r)),
2913 Err(code) => Err(code),
2914 }
2915}
2916
2917fn run_audit_health<'a>(
2919 opts: &'a AuditOptions<'a>,
2920 changed_since: Option<&'a str>,
2921 shared_parse: Option<crate::health::SharedParseData>,
2922) -> Result<Option<HealthResult>, ExitCode> {
2923 let runtime_coverage = match opts.runtime_coverage {
2928 Some(path) => match crate::health::coverage::prepare_options(
2929 path,
2930 opts.min_invocations_hot,
2931 None,
2932 None,
2933 opts.output,
2934 ) {
2935 Ok(options) => Some(options),
2936 Err(code) => return Err(code),
2937 },
2938 None => None,
2939 };
2940
2941 let health_opts = HealthOptions {
2942 root: opts.root,
2943 config_path: opts.config_path,
2944 output: opts.output,
2945 no_cache: opts.no_cache,
2946 threads: opts.threads,
2947 quiet: opts.quiet,
2948 max_cyclomatic: None,
2949 max_cognitive: None,
2950 max_crap: opts.max_crap,
2951 top: None,
2952 sort: SortBy::Cyclomatic,
2953 production: opts.production_health.unwrap_or(opts.production),
2954 production_override: opts.production_health,
2955 changed_since,
2956 workspace: opts.workspace,
2957 changed_workspaces: opts.changed_workspaces,
2958 baseline: opts.health_baseline,
2959 save_baseline: None,
2960 complexity: true,
2961 file_scores: false,
2962 coverage_gaps: false,
2963 config_activates_coverage_gaps: false,
2964 hotspots: false,
2965 ownership: false,
2966 ownership_emails: None,
2967 targets: false,
2968 force_full: false,
2969 score_only_output: false,
2970 enforce_coverage_gap_gate: false,
2971 effort: None,
2972 score: false,
2973 min_score: None,
2974 since: None,
2975 min_commits: None,
2976 explain: opts.explain,
2977 summary: false,
2978 save_snapshot: None,
2979 trend: false,
2980 group_by: opts.group_by,
2981 coverage: opts.coverage,
2982 coverage_root: opts.coverage_root,
2983 performance: opts.performance,
2984 min_severity: None,
2985 runtime_coverage,
2986 };
2987 let health_run = if let Some(shared) = shared_parse {
2988 crate::health::execute_health_with_shared_parse(&health_opts, shared)
2989 } else {
2990 crate::health::execute_health(&health_opts)
2991 };
2992 match health_run {
2993 Ok(r) => Ok(Some(r)),
2994 Err(code) => Err(code),
2995 }
2996}
2997
2998#[must_use]
3002pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
3003 let output = result.output;
3004
3005 let format_exit = match output {
3006 OutputFormat::Json => print_audit_json(result),
3007 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
3008 print_audit_human(result, quiet, explain, output);
3009 ExitCode::SUCCESS
3010 }
3011 OutputFormat::Sarif => print_audit_sarif(result),
3012 OutputFormat::CodeClimate => print_audit_codeclimate(result),
3013 OutputFormat::PrCommentGithub => {
3014 let value = build_audit_codeclimate(result);
3015 report::ci::pr_comment::print_pr_comment(
3016 "audit",
3017 report::ci::pr_comment::Provider::Github,
3018 &value,
3019 )
3020 }
3021 OutputFormat::PrCommentGitlab => {
3022 let value = build_audit_codeclimate(result);
3023 report::ci::pr_comment::print_pr_comment(
3024 "audit",
3025 report::ci::pr_comment::Provider::Gitlab,
3026 &value,
3027 )
3028 }
3029 OutputFormat::ReviewGithub => {
3030 let value = build_audit_codeclimate(result);
3031 report::ci::review::print_review_envelope(
3032 "audit",
3033 report::ci::pr_comment::Provider::Github,
3034 &value,
3035 )
3036 }
3037 OutputFormat::ReviewGitlab => {
3038 let value = build_audit_codeclimate(result);
3039 report::ci::review::print_review_envelope(
3040 "audit",
3041 report::ci::pr_comment::Provider::Gitlab,
3042 &value,
3043 )
3044 }
3045 OutputFormat::Badge => {
3046 eprintln!("Error: badge format is not supported for the audit command");
3047 return ExitCode::from(2);
3048 }
3049 };
3050
3051 if format_exit != ExitCode::SUCCESS {
3052 return format_exit;
3053 }
3054
3055 match result.verdict {
3056 AuditVerdict::Fail => ExitCode::from(1),
3057 AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
3058 }
3059}
3060
3061fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
3064 let show_headers = matches!(output, OutputFormat::Human) && !quiet;
3065
3066 if !quiet {
3068 let scope = format_scope_line(result);
3069 eprintln!();
3070 eprintln!("{scope}");
3071 }
3072
3073 let has_check_issues = result.summary.dead_code_issues > 0;
3074 let has_health_findings = result.summary.complexity_findings > 0;
3075 let has_dupe_groups = result.summary.duplication_clone_groups > 0;
3076 let has_any_findings = has_check_issues || has_health_findings || has_dupe_groups;
3077
3078 if has_any_findings {
3080 if show_headers && std::io::stdout().is_terminal() {
3081 println!(
3082 "{}",
3083 "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
3084 .dimmed()
3085 );
3086 println!();
3087 }
3088
3089 if result.verdict != AuditVerdict::Fail && !quiet {
3091 print_audit_vital_signs(result);
3092 }
3093
3094 if has_check_issues && let Some(ref check) = result.check {
3095 if show_headers {
3096 eprintln!();
3097 eprintln!("── Dead Code ──────────────────────────────────────");
3098 }
3099 crate::check::print_check_result(
3100 check,
3101 crate::check::PrintCheckOptions {
3102 quiet,
3103 explain,
3104 regression_json: false,
3105 group_by: None,
3106 top: None,
3107 summary: false,
3108 summary_heading: true,
3109 show_explain_tip: false,
3110 },
3111 );
3112 }
3113
3114 if has_dupe_groups && let Some(ref dupes) = result.dupes {
3115 if show_headers {
3116 eprintln!();
3117 eprintln!("── Duplication ────────────────────────────────────");
3118 }
3119 crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
3120 }
3121
3122 if has_health_findings && let Some(ref health) = result.health {
3123 if show_headers {
3124 eprintln!();
3125 eprintln!("── Complexity ─────────────────────────────────────");
3126 }
3127 crate::health::print_health_result(
3131 health, quiet, explain, None, None, false, true, false, false,
3132 );
3133 }
3134 }
3135
3136 if !has_dupe_groups && let Some(ref dupes) = result.dupes {
3137 crate::dupes::print_default_ignore_note(dupes, quiet);
3138 crate::dupes::print_min_occurrences_note(dupes, quiet);
3139 }
3140
3141 if !quiet {
3143 print_audit_status_line(result);
3144 }
3145}
3146
3147fn format_scope_line(result: &AuditResult) -> String {
3149 let sha_suffix = result
3150 .head_sha
3151 .as_ref()
3152 .map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
3153 format!(
3154 "Audit scope: {} changed file{} vs {}{}",
3155 result.changed_files_count,
3156 plural(result.changed_files_count),
3157 result.base_ref,
3158 sha_suffix
3159 )
3160}
3161
3162fn print_audit_vital_signs(result: &AuditResult) {
3164 let mut parts = Vec::new();
3165 parts.push(format!("dead code {}", result.summary.dead_code_issues));
3166 if let Some(max) = result.summary.max_cyclomatic {
3167 parts.push(format!(
3168 "complexity {} (warn, max cyclomatic: {max})",
3169 result.summary.complexity_findings
3170 ));
3171 } else {
3172 parts.push(format!("complexity {}", result.summary.complexity_findings));
3173 }
3174 parts.push(format!(
3175 "duplication {}",
3176 result.summary.duplication_clone_groups
3177 ));
3178
3179 let line = parts.join(" \u{00b7} ");
3180 println!(
3181 "{} {} {}",
3182 "\u{25a0}".dimmed(),
3183 "Metrics:".dimmed(),
3184 line.dimmed()
3185 );
3186}
3187
3188fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
3190 let mut parts = Vec::new();
3191 if summary.dead_code_issues > 0 {
3192 let n = summary.dead_code_issues;
3193 parts.push(format!("dead code: {n} issue{}", plural(n)));
3194 }
3195 if summary.complexity_findings > 0 {
3196 let n = summary.complexity_findings;
3197 parts.push(format!("complexity: {n} finding{}", plural(n)));
3198 }
3199 if summary.duplication_clone_groups > 0 {
3200 let n = summary.duplication_clone_groups;
3201 parts.push(format!("duplication: {n} clone group{}", plural(n)));
3202 }
3203 parts
3204}
3205
3206fn print_audit_status_line(result: &AuditResult) {
3208 let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
3209 let n = result.changed_files_count;
3210 let files_str = format!("{n} changed file{}", plural(n));
3211
3212 match result.verdict {
3213 AuditVerdict::Pass => {
3214 eprintln!(
3215 "{}",
3216 format!("\u{2713} No issues in {files_str} ({elapsed_str})")
3217 .green()
3218 .bold()
3219 );
3220 }
3221 AuditVerdict::Warn => {
3222 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
3223 eprintln!(
3224 "{}",
3225 format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
3226 .green()
3227 .bold()
3228 );
3229 }
3230 AuditVerdict::Fail => {
3231 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
3232 eprintln!(
3233 "{}",
3234 format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
3235 .red()
3236 .bold()
3237 );
3238 }
3239 }
3240
3241 if !matches!(result.attribution.gate, AuditGate::All) {
3242 let inherited = result.attribution.dead_code_inherited
3243 + result.attribution.complexity_inherited
3244 + result.attribution.duplication_inherited;
3245 if inherited > 0 {
3246 eprintln!(
3247 " {}",
3248 format!(
3249 "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
3250 plural(inherited)
3251 )
3252 .dimmed()
3253 );
3254 }
3255 }
3256 if result.performance {
3257 eprintln!(
3258 " {}",
3259 format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
3260 );
3261 }
3262}
3263
3264#[expect(
3267 clippy::cast_possible_truncation,
3268 reason = "elapsed milliseconds won't exceed u64::MAX"
3269)]
3270fn print_audit_json(result: &AuditResult) -> ExitCode {
3271 let mut obj = serde_json::Map::new();
3272 obj.insert(
3273 "schema_version".into(),
3274 serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
3275 );
3276 obj.insert(
3277 "version".into(),
3278 serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
3279 );
3280 obj.insert(
3281 "command".into(),
3282 serde_json::Value::String("audit".to_string()),
3283 );
3284 obj.insert(
3285 "verdict".into(),
3286 serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
3287 );
3288 obj.insert(
3289 "changed_files_count".into(),
3290 serde_json::Value::Number(result.changed_files_count.into()),
3291 );
3292 obj.insert(
3293 "base_ref".into(),
3294 serde_json::Value::String(result.base_ref.clone()),
3295 );
3296 if let Some(ref sha) = result.head_sha {
3297 obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
3298 }
3299 obj.insert(
3300 "elapsed_ms".into(),
3301 serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
3302 );
3303 if result.performance {
3304 obj.insert(
3305 "base_snapshot_skipped".into(),
3306 serde_json::Value::Bool(result.base_snapshot_skipped),
3307 );
3308 }
3309
3310 if let Ok(summary_val) = serde_json::to_value(&result.summary) {
3312 obj.insert("summary".into(), summary_val);
3313 }
3314 if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
3315 obj.insert("attribution".into(), attribution_val);
3316 }
3317
3318 if let Some(ref check) = result.check {
3320 match report::build_json_with_config_fixable(
3321 &check.results,
3322 &check.config.root,
3323 check.elapsed,
3324 check.config_fixable,
3325 ) {
3326 Ok(mut json) => {
3327 if let Some(ref base) = result.base_snapshot {
3328 annotate_dead_code_json(
3329 &mut json,
3330 &check.results,
3331 &check.config.root,
3332 &base.dead_code,
3333 );
3334 }
3335 obj.insert("dead_code".into(), json);
3336 }
3337 Err(e) => {
3338 return emit_error(
3339 &format!("JSON serialization error: {e}"),
3340 2,
3341 OutputFormat::Json,
3342 );
3343 }
3344 }
3345 }
3346
3347 if let Some(ref dupes) = result.dupes {
3348 let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
3349 match serde_json::to_value(&payload) {
3350 Ok(mut json) => {
3351 let root_prefix = format!("{}/", dupes.config.root.display());
3352 report::strip_root_prefix(&mut json, &root_prefix);
3353 if let Some(ref base) = result.base_snapshot {
3354 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
3355 }
3356 obj.insert("duplication".into(), json);
3357 }
3358 Err(e) => {
3359 return emit_error(
3360 &format!("JSON serialization error: {e}"),
3361 2,
3362 OutputFormat::Json,
3363 );
3364 }
3365 }
3366 }
3367
3368 if let Some(ref health) = result.health {
3369 match serde_json::to_value(&health.report) {
3370 Ok(mut json) => {
3371 let root_prefix = format!("{}/", health.config.root.display());
3372 report::strip_root_prefix(&mut json, &root_prefix);
3373 if let Some(ref base) = result.base_snapshot {
3374 annotate_health_json(
3375 &mut json,
3376 &health.report,
3377 &health.config.root,
3378 &base.health,
3379 );
3380 }
3381 obj.insert("complexity".into(), json);
3382 }
3383 Err(e) => {
3384 return emit_error(
3385 &format!("JSON serialization error: {e}"),
3386 2,
3387 OutputFormat::Json,
3388 );
3389 }
3390 }
3391 }
3392
3393 let mut output = serde_json::Value::Object(obj);
3394 report::harmonize_multi_kind_suppress_line_actions(&mut output);
3395 report::emit_json(&output, "audit")
3396}
3397
3398fn print_audit_sarif(result: &AuditResult) -> ExitCode {
3401 let mut all_runs = Vec::new();
3402
3403 if let Some(ref check) = result.check {
3404 let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
3405 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
3406 all_runs.extend(runs.iter().cloned());
3407 }
3408 }
3409
3410 if let Some(ref dupes) = result.dupes
3411 && !dupes.report.clone_groups.is_empty()
3412 {
3413 let run = serde_json::json!({
3414 "tool": {
3415 "driver": {
3416 "name": "fallow",
3417 "version": env!("CARGO_PKG_VERSION"),
3418 "informationUri": "https://github.com/fallow-rs/fallow",
3419 }
3420 },
3421 "automationDetails": { "id": "fallow/audit/dupes" },
3422 "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
3423 serde_json::json!({
3424 "ruleId": "fallow/code-duplication",
3425 "level": "warning",
3426 "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
3427 })
3428 }).collect::<Vec<_>>()
3429 });
3430 all_runs.push(run);
3431 }
3432
3433 if let Some(ref health) = result.health {
3434 let sarif = report::build_health_sarif(&health.report, &health.config.root);
3435 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
3436 all_runs.extend(runs.iter().cloned());
3437 }
3438 }
3439
3440 let combined = serde_json::json!({
3441 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3442 "version": "2.1.0",
3443 "runs": all_runs,
3444 });
3445
3446 report::emit_json(&combined, "SARIF audit")
3447}
3448
3449fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
3452 let value = build_audit_codeclimate(result);
3453 report::emit_json(&value, "CodeClimate audit")
3454}
3455
3456fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
3457 let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
3458
3459 if let Some(ref check) = result.check {
3460 all_issues.extend(report::build_codeclimate(
3461 &check.results,
3462 &check.config.root,
3463 &check.config.rules,
3464 ));
3465 }
3466
3467 if let Some(ref dupes) = result.dupes {
3468 all_issues.extend(report::build_duplication_codeclimate(
3469 &dupes.report,
3470 &dupes.config.root,
3471 ));
3472 }
3473
3474 if let Some(ref health) = result.health {
3475 all_issues.extend(report::build_health_codeclimate(
3476 &health.report,
3477 &health.config.root,
3478 ));
3479 }
3480
3481 serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
3482}
3483
3484pub fn run_audit(opts: &AuditOptions<'_>) -> ExitCode {
3488 if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
3489 return emit_error(&e, 2, opts.output);
3490 }
3491 let coverage_resolved = opts
3499 .coverage
3500 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
3501 let runtime_coverage_resolved = opts
3509 .runtime_coverage
3510 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
3511 let resolved_opts = AuditOptions {
3512 coverage: coverage_resolved.as_deref(),
3513 runtime_coverage: runtime_coverage_resolved.as_deref(),
3514 ..*opts
3515 };
3516 match execute_audit(&resolved_opts) {
3517 Ok(result) => print_audit_result(&result, opts.quiet, opts.explain),
3518 Err(code) => code,
3519 }
3520}
3521
3522#[cfg(test)]
3523mod tests {
3524 use super::*;
3525 use std::{fs, process::Command};
3526
3527 fn git(dir: &std::path::Path, args: &[&str]) {
3528 let output = Command::new("git")
3529 .args(args)
3530 .current_dir(dir)
3531 .env_remove("GIT_DIR")
3532 .env_remove("GIT_WORK_TREE")
3533 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3534 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3535 .env("GIT_AUTHOR_NAME", "test")
3536 .env("GIT_AUTHOR_EMAIL", "test@test.com")
3537 .env("GIT_COMMITTER_NAME", "test")
3538 .env("GIT_COMMITTER_EMAIL", "test@test.com")
3539 .output()
3540 .expect("git command failed");
3541 assert!(
3542 output.status.success(),
3543 "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
3544 args,
3545 String::from_utf8_lossy(&output.stdout),
3546 String::from_utf8_lossy(&output.stderr)
3547 );
3548 }
3549
3550 #[test]
3551 fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
3552 let temp = std::env::temp_dir();
3553 let audit_path = temp.join("fallow-audit-base-123-456");
3554 let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
3555 let canonical_audit_path = temp
3556 .canonicalize()
3557 .unwrap_or_else(|_| temp.clone())
3558 .join("fallow-audit-base-456-789");
3559 let unrelated_temp = temp.join("other-worktree");
3560 let output = format!(
3561 "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
3562 audit_path.display(),
3563 unrelated_temp.display(),
3564 reusable_path.display()
3565 );
3566
3567 assert_eq!(
3568 parse_worktree_list(&output),
3569 vec![audit_path, reusable_path.clone()]
3570 );
3571 assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
3572 assert!(is_reusable_audit_worktree_path(&reusable_path));
3573 assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
3574 assert_eq!(
3575 audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
3576 None
3577 );
3578 assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
3579 }
3580
3581 fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
3585 let root = parent.join(name);
3586 fs::create_dir_all(&root).expect("repo root should be created");
3587 fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
3588 git(&root, &["init", "-b", "main"]);
3589 git(&root, &["add", "."]);
3590 git(
3591 &root,
3592 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3593 );
3594 root
3595 }
3596
3597 fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3598 list_audit_worktrees(repo_root)
3599 .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
3600 }
3601
3602 #[test]
3603 fn worktree_cleanup_guard_runs_on_drop() {
3604 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3605 let repo = init_throwaway_repo(tmp.path(), "repo");
3606 let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
3607
3608 git(
3611 &repo,
3612 &[
3613 "worktree",
3614 "add",
3615 "--detach",
3616 "--quiet",
3617 worktree_path.to_str().expect("path is utf-8"),
3618 "HEAD",
3619 ],
3620 );
3621 assert!(worktree_path.is_dir());
3622 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3623
3624 {
3625 let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3626 }
3628
3629 assert!(
3630 !worktree_path.exists(),
3631 "guard Drop should remove the worktree directory",
3632 );
3633 assert!(
3634 !worktree_is_registered_with_git(&repo, &worktree_path),
3635 "guard Drop should remove the git worktree registration",
3636 );
3637 }
3638
3639 #[test]
3640 fn worktree_cleanup_guard_defused_skips_drop() {
3641 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3642 let repo = init_throwaway_repo(tmp.path(), "repo");
3643 let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
3644
3645 git(
3646 &repo,
3647 &[
3648 "worktree",
3649 "add",
3650 "--detach",
3651 "--quiet",
3652 worktree_path.to_str().expect("path is utf-8"),
3653 "HEAD",
3654 ],
3655 );
3656 assert!(worktree_path.is_dir());
3657
3658 {
3659 let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3660 guard.defuse();
3661 guard.defuse();
3663 }
3664
3665 assert!(
3666 worktree_path.is_dir(),
3667 "defused guard must not remove the worktree on drop",
3668 );
3669 assert!(
3670 worktree_is_registered_with_git(&repo, &worktree_path),
3671 "defused guard must not unregister the worktree from git",
3672 );
3673
3674 remove_audit_worktree(&repo, &worktree_path);
3676 let _ = fs::remove_dir_all(&worktree_path);
3677 }
3678
3679 #[test]
3680 fn audit_orphan_sweep_removes_dead_pid_worktree() {
3681 const DEAD_PID: u32 = 99_999_999;
3688 assert!(!process_is_alive(DEAD_PID));
3689
3690 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3691 let repo = init_throwaway_repo(tmp.path(), "repo");
3692
3693 let worktree_path = std::env::temp_dir().join(format!(
3696 "fallow-audit-base-{}-{}",
3697 DEAD_PID,
3698 std::time::SystemTime::now()
3699 .duration_since(std::time::UNIX_EPOCH)
3700 .expect("clock should be after epoch")
3701 .as_nanos()
3702 ));
3703 git(
3704 &repo,
3705 &[
3706 "worktree",
3707 "add",
3708 "--detach",
3709 "--quiet",
3710 worktree_path.to_str().expect("path is utf-8"),
3711 "HEAD",
3712 ],
3713 );
3714 assert!(worktree_path.is_dir());
3715 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3716
3717 sweep_orphan_audit_worktrees(&repo);
3718
3719 assert!(
3720 !worktree_path.exists(),
3721 "sweep should remove worktree owned by a dead PID",
3722 );
3723 assert!(
3724 !worktree_is_registered_with_git(&repo, &worktree_path),
3725 "sweep should unregister worktree owned by a dead PID",
3726 );
3727 }
3728
3729 #[test]
3730 fn audit_orphan_sweep_keeps_live_pid_worktree() {
3731 let live_pid = std::process::id();
3732 assert!(process_is_alive(live_pid));
3733
3734 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3735 let repo = init_throwaway_repo(tmp.path(), "repo");
3736
3737 let worktree_path = std::env::temp_dir().join(format!(
3738 "fallow-audit-base-{}-{}",
3739 live_pid,
3740 std::time::SystemTime::now()
3741 .duration_since(std::time::UNIX_EPOCH)
3742 .expect("clock should be after epoch")
3743 .as_nanos()
3744 ));
3745 git(
3746 &repo,
3747 &[
3748 "worktree",
3749 "add",
3750 "--detach",
3751 "--quiet",
3752 worktree_path.to_str().expect("path is utf-8"),
3753 "HEAD",
3754 ],
3755 );
3756
3757 sweep_orphan_audit_worktrees(&repo);
3758
3759 assert!(
3760 worktree_path.is_dir(),
3761 "sweep must not remove worktree owned by a live PID",
3762 );
3763 assert!(
3764 worktree_is_registered_with_git(&repo, &worktree_path),
3765 "sweep must not unregister worktree owned by a live PID",
3766 );
3767
3768 remove_audit_worktree(&repo, &worktree_path);
3770 let _ = fs::remove_dir_all(&worktree_path);
3771 }
3772
3773 fn make_reusable_path(label: &str) -> PathBuf {
3777 let nanos = std::time::SystemTime::now()
3778 .duration_since(std::time::UNIX_EPOCH)
3779 .expect("clock should be after epoch")
3780 .as_nanos();
3781 std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
3782 }
3783
3784 fn register_reusable_worktree(repo: &Path, path: &Path) {
3788 git(
3789 repo,
3790 &[
3791 "worktree",
3792 "add",
3793 "--detach",
3794 "--quiet",
3795 path.to_str().expect("path is utf-8"),
3796 "HEAD",
3797 ],
3798 );
3799 }
3800
3801 fn write_sidecar_with_age(path: &Path, age: Duration) {
3802 let sidecar = reusable_worktree_last_used_path(path);
3803 let file = std::fs::OpenOptions::new()
3804 .create(true)
3805 .truncate(false)
3806 .write(true)
3807 .open(&sidecar)
3808 .expect("sidecar should open");
3809 let when = SystemTime::now()
3810 .checked_sub(age)
3811 .expect("backdated time should fit in SystemTime");
3812 file.set_modified(when)
3813 .expect("set_modified should succeed");
3814 }
3815
3816 fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
3819 remove_audit_worktree(repo, path);
3820 let _ = fs::remove_dir_all(path);
3821 let _ = fs::remove_file(reusable_worktree_last_used_path(path));
3822 let _ = fs::remove_file(reusable_worktree_lock_path(path));
3823 }
3824
3825 #[test]
3826 fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
3827 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3828 let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
3829 let worktree_path = make_reusable_path("gc-remove");
3830 register_reusable_worktree(&repo, &worktree_path);
3831 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3832
3833 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3834
3835 assert!(
3836 !worktree_path.exists(),
3837 "sweep should remove worktree dir whose sidecar is older than the threshold",
3838 );
3839 assert!(
3840 !worktree_is_registered_with_git(&repo, &worktree_path),
3841 "sweep should unregister the worktree from git",
3842 );
3843 assert!(
3844 !reusable_worktree_last_used_path(&worktree_path).exists(),
3845 "sweep should remove the sidecar `.last-used` file alongside the worktree",
3846 );
3847 cleanup_reusable_worktree(&repo, &worktree_path);
3850 }
3851
3852 #[test]
3853 fn reusable_cache_gc_keeps_fresh_entry() {
3854 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3855 let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
3856 let worktree_path = make_reusable_path("gc-keep");
3857 register_reusable_worktree(&repo, &worktree_path);
3858 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3859
3860 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3861
3862 assert!(
3863 worktree_path.is_dir(),
3864 "sweep must not remove a worktree whose sidecar is fresher than the threshold",
3865 );
3866 assert!(
3867 worktree_is_registered_with_git(&repo, &worktree_path),
3868 "sweep must not unregister a fresh worktree",
3869 );
3870 cleanup_reusable_worktree(&repo, &worktree_path);
3871 }
3872
3873 #[test]
3874 fn reusable_cache_gc_skips_locked_entry() {
3875 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3876 let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
3877 let worktree_path = make_reusable_path("gc-locked");
3878 register_reusable_worktree(&repo, &worktree_path);
3879 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3880
3881 let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
3884 .expect("test should acquire the lock first");
3885
3886 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3887
3888 assert!(
3889 worktree_path.is_dir(),
3890 "sweep must skip a locked entry even when its sidecar is stale",
3891 );
3892 assert!(
3893 worktree_is_registered_with_git(&repo, &worktree_path),
3894 "sweep must not unregister a locked entry",
3895 );
3896 drop(lock);
3897 cleanup_reusable_worktree(&repo, &worktree_path);
3898 }
3899
3900 #[test]
3901 fn reusable_cache_gc_grace_when_sidecar_absent() {
3902 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3903 let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
3904 let worktree_path = make_reusable_path("gc-grace");
3905 register_reusable_worktree(&repo, &worktree_path);
3906 let sidecar = reusable_worktree_last_used_path(&worktree_path);
3912 assert!(
3913 !sidecar.exists(),
3914 "test pre-condition: sidecar should not exist",
3915 );
3916
3917 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3918
3919 assert!(
3920 worktree_path.is_dir(),
3921 "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
3922 );
3923 assert!(
3924 sidecar.exists(),
3925 "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
3926 );
3927 let mtime = std::fs::metadata(&sidecar)
3928 .and_then(|m| m.modified())
3929 .expect("seeded sidecar should have a readable mtime");
3930 let age = SystemTime::now()
3931 .duration_since(mtime)
3932 .unwrap_or(Duration::ZERO);
3933 assert!(
3934 age < Duration::from_mins(1),
3935 "seeded sidecar mtime should be near `now()`, got age {age:?}",
3936 );
3937 cleanup_reusable_worktree(&repo, &worktree_path);
3938 }
3939
3940 #[test]
3941 fn reusable_cache_gc_preserves_lock_file_after_removal() {
3942 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3949 let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
3950 let worktree_path = make_reusable_path("gc-lockfile");
3951 register_reusable_worktree(&repo, &worktree_path);
3952 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3953 let lock_path = reusable_worktree_lock_path(&worktree_path);
3957 drop(
3958 ReusableWorktreeLock::try_acquire(&worktree_path)
3959 .expect("test should acquire the lock"),
3960 );
3961 assert!(
3962 lock_path.exists(),
3963 "test pre-condition: lock file should exist before sweep",
3964 );
3965
3966 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3967
3968 assert!(
3969 !worktree_path.exists(),
3970 "sweep should still remove the worktree directory",
3971 );
3972 assert!(
3973 lock_path.exists(),
3974 "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
3975 );
3976 let _ = fs::remove_file(&lock_path);
3977 cleanup_reusable_worktree(&repo, &worktree_path);
3978 }
3979
3980 #[test]
3981 fn reuse_or_create_stamps_sidecar_on_fresh_create_and_age_threshold_applies() {
3982 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3991 let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
3992 let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
3993
3994 let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
3995 .expect("fresh reuse_or_create should succeed on a clean repo");
3996 let cache_path = worktree.path().to_path_buf();
3997 let sidecar = reusable_worktree_last_used_path(&cache_path);
3998
3999 assert!(
4000 sidecar.exists(),
4001 "fresh-create must write the sidecar so age is measured from now",
4002 );
4003 let initial_age = std::fs::metadata(&sidecar)
4004 .and_then(|m| m.modified())
4005 .ok()
4006 .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
4007 .expect("sidecar mtime should be readable and not in the future");
4008 assert!(
4009 initial_age < Duration::from_mins(1),
4010 "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
4011 );
4012
4013 drop(worktree);
4016
4017 write_sidecar_with_age(&cache_path, Duration::from_hours(31 * 24));
4019 sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
4020
4021 assert!(
4022 !cache_path.exists(),
4023 "after backdating, sweep must remove the fresh-created cache",
4024 );
4025 assert!(
4026 !sidecar.exists(),
4027 "sweep should remove the sidecar alongside the cache dir",
4028 );
4029 cleanup_reusable_worktree(&repo, &cache_path);
4030 }
4031
4032 #[test]
4033 fn days_to_duration_zero_disables() {
4034 assert!(days_to_duration(0).is_none());
4035 assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
4036 assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
4037 }
4038
4039 #[test]
4040 fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
4041 let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
4042 let sidecar = reusable_worktree_last_used_path(&cache_dir);
4043 assert_eq!(sidecar.parent(), cache_dir.parent());
4044 assert_eq!(
4045 sidecar.file_name().and_then(|s| s.to_str()),
4046 Some("fallow-audit-base-cache-abcd-1234.last-used"),
4047 );
4048 }
4049
4050 #[test]
4051 fn touch_last_used_creates_sidecar_if_missing() {
4052 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4053 let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
4054 fs::create_dir(&cache_dir).expect("cache dir should be created");
4055 let sidecar = reusable_worktree_last_used_path(&cache_dir);
4056 assert!(!sidecar.exists(), "sidecar should not exist before touch");
4057
4058 touch_last_used(&cache_dir);
4059
4060 assert!(sidecar.exists(), "touch should create the sidecar");
4061 let mtime = fs::metadata(&sidecar)
4062 .and_then(|m| m.modified())
4063 .expect("sidecar should have an mtime");
4064 let age = SystemTime::now()
4065 .duration_since(mtime)
4066 .unwrap_or(Duration::ZERO);
4067 assert!(
4068 age < Duration::from_mins(1),
4069 "touched sidecar should be near `now()`",
4070 );
4071 }
4072
4073 #[test]
4074 fn reusable_worktree_lock_excludes_concurrent_acquires() {
4075 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4076 let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
4079 let lock_path = reusable_worktree_lock_path(&reusable);
4080
4081 let first = ReusableWorktreeLock::try_acquire(&reusable)
4082 .expect("first acquire on a fresh path should succeed");
4083 assert!(
4084 ReusableWorktreeLock::try_acquire(&reusable).is_none(),
4085 "second acquire must fail while the first is held",
4086 );
4087 drop(first);
4095 assert!(
4099 lock_path.exists(),
4100 "lock file must persist after drop (only the kernel lock is released)",
4101 );
4102 }
4103
4104 #[test]
4105 fn base_analysis_root_preserves_repo_subdirectory_roots() {
4106 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4107 let repo = tmp.path().join("repo");
4108 let app_root = repo.join("apps/mobile");
4109 let base_worktree = tmp.path().join("base-worktree");
4110 fs::create_dir_all(&app_root).expect("app root should be created");
4111 fs::create_dir_all(&base_worktree).expect("base worktree should be created");
4112 git(&repo, &["init", "-b", "main"]);
4113
4114 assert_eq!(
4115 base_analysis_root(&app_root, &base_worktree),
4116 base_worktree.join("apps/mobile")
4117 );
4118 }
4119
4120 #[test]
4121 fn audit_base_worktree_reuses_current_node_modules_context() {
4122 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4123 let root = tmp.path();
4124 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4125 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
4126 .expect("gitignore should be written");
4127 fs::write(
4128 root.join("package.json"),
4129 r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
4130 )
4131 .expect("package.json should be written");
4132 fs::write(
4133 root.join("tsconfig.json"),
4134 r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
4135 )
4136 .expect("tsconfig should be written");
4137 fs::write(
4138 root.join("src/index.ts"),
4139 "import { used } from '@/feature';\nconsole.log(used);\n",
4140 )
4141 .expect("index should be written");
4142 fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
4143 .expect("feature should be written");
4144
4145 git(root, &["init", "-b", "main"]);
4146 git(root, &["add", "."]);
4147 git(
4148 root,
4149 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4150 );
4151
4152 let rn_config = root.join("node_modules/@react-native/typescript-config");
4153 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
4154 fs::write(
4155 rn_config.join("tsconfig.json"),
4156 r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
4157 )
4158 .expect("node_modules tsconfig should be written");
4159
4160 let worktree =
4161 BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
4162 assert!(
4163 worktree.path().join("node_modules").is_dir(),
4164 "base worktree should reuse ignored node_modules from the current checkout"
4165 );
4166 assert!(
4167 worktree
4168 .path()
4169 .join("node_modules/@react-native/typescript-config/tsconfig.json")
4170 .is_file(),
4171 "base worktree should preserve tsconfig extends targets installed in node_modules"
4172 );
4173 }
4174
4175 #[test]
4176 fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
4177 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4178 let root = tmp.path();
4179 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
4180 .expect("gitignore should be written");
4181 fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
4182 .expect("package.json should be written");
4183
4184 git(root, &["init", "-b", "main"]);
4185 git(root, &["add", "."]);
4186 git(
4187 root,
4188 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4189 );
4190
4191 let rn_config = root.join("node_modules/@react-native/typescript-config");
4192 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
4193 fs::write(rn_config.join("tsconfig.json"), "{}")
4194 .expect("node_modules tsconfig should be written");
4195
4196 let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
4197 let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
4198 .expect("persistent base worktree should be created");
4199 let worktree_path = first.path().to_path_buf();
4200 assert!(
4201 worktree_path.join("node_modules").is_dir(),
4202 "initial persistent worktree should receive node_modules context"
4203 );
4204 remove_node_modules_context(&worktree_path);
4205 assert!(
4206 !worktree_path.join("node_modules").exists(),
4207 "test setup should remove the dependency context from the reusable worktree"
4208 );
4209 drop(first);
4210
4211 let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
4212 .expect("ready persistent base worktree should be reused");
4213 assert_eq!(reused.path(), worktree_path.as_path());
4214 assert!(
4215 reused.path().join("node_modules").is_dir(),
4216 "ready persistent worktree should refresh missing node_modules context"
4217 );
4218
4219 remove_audit_worktree(root, reused.path());
4220 let _ = fs::remove_dir_all(reused.path());
4221 }
4222
4223 fn remove_node_modules_context(worktree_path: &Path) {
4224 let path = worktree_path.join("node_modules");
4225 let Ok(metadata) = fs::symlink_metadata(&path) else {
4226 return;
4227 };
4228 if metadata.file_type().is_symlink() {
4229 #[cfg(unix)]
4230 let _ = fs::remove_file(path);
4231 #[cfg(windows)]
4232 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
4233 } else {
4234 let _ = fs::remove_dir_all(path);
4235 }
4236 }
4237
4238 #[test]
4239 fn audit_base_snapshot_cache_payload_roundtrips_sets() {
4240 let key = AuditBaseSnapshotCacheKey {
4241 hash: 42,
4242 base_sha: "abc123".to_string(),
4243 };
4244 let snapshot = AuditKeySnapshot {
4245 dead_code: ["dead:a".to_string(), "dead:b".to_string()]
4246 .into_iter()
4247 .collect(),
4248 health: std::iter::once("health:a".to_string()).collect(),
4249 dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
4250 .into_iter()
4251 .collect(),
4252 };
4253
4254 let cached = cached_from_snapshot(&key, &snapshot);
4255 assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
4256 assert_eq!(cached.key_hash, key.hash);
4257 assert_eq!(cached.base_sha, key.base_sha);
4258 assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
4259
4260 let decoded = snapshot_from_cached(cached);
4261 assert_eq!(decoded.dead_code, snapshot.dead_code);
4262 assert_eq!(decoded.health, snapshot.health);
4263 assert_eq!(decoded.dupes, snapshot.dupes);
4264 }
4265
4266 #[test]
4267 fn audit_base_snapshot_cache_key_includes_extended_config() {
4268 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4269 let root = tmp.path();
4270 fs::write(
4271 root.join(".fallowrc.json"),
4272 r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
4273 )
4274 .expect("config should be written");
4275 fs::write(
4276 root.join("base.json"),
4277 r#"{"rules":{"unused-exports":"off"}}"#,
4278 )
4279 .expect("base config should be written");
4280
4281 let config_path = None;
4282 let opts = AuditOptions {
4283 root,
4284 config_path: &config_path,
4285 output: OutputFormat::Json,
4286 no_cache: false,
4287 threads: 1,
4288 quiet: true,
4289 changed_since: Some("HEAD"),
4290 production: false,
4291 production_dead_code: None,
4292 production_health: None,
4293 production_dupes: None,
4294 workspace: None,
4295 changed_workspaces: None,
4296 explain: false,
4297 explain_skipped: false,
4298 performance: false,
4299 group_by: None,
4300 dead_code_baseline: None,
4301 health_baseline: None,
4302 dupes_baseline: None,
4303 max_crap: None,
4304 coverage: None,
4305 coverage_root: None,
4306 gate: AuditGate::NewOnly,
4307 include_entry_exports: false,
4308 runtime_coverage: None,
4309 min_invocations_hot: 100,
4310 };
4311
4312 let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
4313 fs::write(
4314 root.join("base.json"),
4315 r#"{"rules":{"unused-exports":"error"}}"#,
4316 )
4317 .expect("base config should be updated");
4318 let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
4319
4320 assert_ne!(
4321 first["resolved_hash"], second["resolved_hash"],
4322 "extended config changes must invalidate cached base snapshots"
4323 );
4324 }
4325
4326 #[test]
4327 fn audit_gate_all_skips_base_snapshot() {
4328 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4329 let root = tmp.path();
4330 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4331 fs::write(
4332 root.join("package.json"),
4333 r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
4334 )
4335 .expect("package.json should be written");
4336 fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
4337 .expect("index should be written");
4338
4339 git(root, &["init", "-b", "main"]);
4340 git(root, &["add", "."]);
4341 git(
4342 root,
4343 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4344 );
4345 fs::write(
4346 root.join("src/index.ts"),
4347 "export const legacy = 1;\nexport const changed = 2;\n",
4348 )
4349 .expect("changed module should be written");
4350
4351 let config_path = None;
4352 let opts = AuditOptions {
4353 root,
4354 config_path: &config_path,
4355 output: OutputFormat::Json,
4356 no_cache: true,
4357 threads: 1,
4358 quiet: true,
4359 changed_since: Some("HEAD"),
4360 production: false,
4361 production_dead_code: None,
4362 production_health: None,
4363 production_dupes: None,
4364 workspace: None,
4365 changed_workspaces: None,
4366 explain: false,
4367 explain_skipped: false,
4368 performance: false,
4369 group_by: None,
4370 dead_code_baseline: None,
4371 health_baseline: None,
4372 dupes_baseline: None,
4373 max_crap: None,
4374 coverage: None,
4375 coverage_root: None,
4376 gate: AuditGate::All,
4377 include_entry_exports: false,
4378 runtime_coverage: None,
4379 min_invocations_hot: 100,
4380 };
4381
4382 let result = execute_audit(&opts).expect("audit should execute");
4383 assert!(result.base_snapshot.is_none());
4384 assert_eq!(result.attribution.gate, AuditGate::All);
4385 assert_eq!(result.attribution.dead_code_introduced, 0);
4386 assert_eq!(result.attribution.dead_code_inherited, 0);
4387 }
4388
4389 #[test]
4390 fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
4391 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4392 let root = tmp.path();
4393 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4394 fs::write(
4395 root.join("package.json"),
4396 r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
4397 )
4398 .expect("package.json should be written");
4399 fs::write(
4400 root.join(".fallowrc.json"),
4401 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
4402 )
4403 .expect("config should be written");
4404 let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
4405 fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
4406 fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
4407 fs::write(root.join("README.md"), "before\n").expect("readme should be written");
4408
4409 git(root, &["init", "-b", "main"]);
4410 git(root, &["add", "."]);
4411 git(
4412 root,
4413 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4414 );
4415 fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
4416 fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
4417 .expect("cache dir should be created");
4418 fs::write(
4419 root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
4420 b"cache",
4421 )
4422 .expect("cache artifact should be written");
4423
4424 let before_worktrees = audit_worktree_names(root);
4425
4426 let config_path = None;
4427 let opts = AuditOptions {
4428 root,
4429 config_path: &config_path,
4430 output: OutputFormat::Json,
4431 no_cache: true,
4432 threads: 1,
4433 quiet: true,
4434 changed_since: Some("HEAD"),
4435 production: false,
4436 production_dead_code: None,
4437 production_health: None,
4438 production_dupes: None,
4439 workspace: None,
4440 changed_workspaces: None,
4441 explain: false,
4442 explain_skipped: false,
4443 performance: true,
4444 group_by: None,
4445 dead_code_baseline: None,
4446 health_baseline: None,
4447 dupes_baseline: None,
4448 max_crap: None,
4449 coverage: None,
4450 coverage_root: None,
4451 gate: AuditGate::NewOnly,
4452 include_entry_exports: false,
4453 runtime_coverage: None,
4454 min_invocations_hot: 100,
4455 };
4456
4457 let result = execute_audit(&opts).expect("audit should execute");
4458 assert_eq!(result.verdict, AuditVerdict::Pass);
4459 assert_eq!(result.changed_files_count, 2);
4460 assert!(result.base_snapshot_skipped);
4461 assert!(result.base_snapshot.is_some());
4462
4463 let after_worktrees = audit_worktree_names(root);
4464 assert_eq!(
4465 before_worktrees, after_worktrees,
4466 "base snapshot skip must not create a temporary base worktree"
4467 );
4468 }
4469
4470 fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
4471 let mut names: Vec<String> = list_audit_worktrees(repo_root)
4472 .unwrap_or_default()
4473 .into_iter()
4474 .filter_map(|path| {
4475 path.file_name()
4476 .and_then(|name| name.to_str())
4477 .map(str::to_owned)
4478 })
4479 .collect();
4480 names.sort();
4481 names
4482 }
4483
4484 #[test]
4485 fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
4486 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4487 let root = tmp.path();
4488 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4489 fs::write(
4490 root.join("package.json"),
4491 r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
4492 )
4493 .expect("package.json should be written");
4494 fs::write(
4495 root.join("src/index.ts"),
4496 "import { used } from './used';\nused();\n",
4497 )
4498 .expect("index should be written");
4499 fs::write(
4500 root.join("src/used.ts"),
4501 "export function used() {\n return 1;\n}\n",
4502 )
4503 .expect("used module should be written");
4504
4505 git(root, &["init", "-b", "main"]);
4506 git(root, &["add", "."]);
4507 git(
4508 root,
4509 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4510 );
4511 fs::write(
4512 root.join("src/used.ts"),
4513 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
4514 )
4515 .expect("changed module should be written");
4516
4517 let config_path = None;
4518 let opts = AuditOptions {
4519 root,
4520 config_path: &config_path,
4521 output: OutputFormat::Json,
4522 no_cache: true,
4523 threads: 1,
4524 quiet: true,
4525 changed_since: Some("HEAD"),
4526 production: false,
4527 production_dead_code: None,
4528 production_health: None,
4529 production_dupes: None,
4530 workspace: None,
4531 changed_workspaces: None,
4532 explain: false,
4533 explain_skipped: false,
4534 performance: true,
4535 group_by: None,
4536 dead_code_baseline: None,
4537 health_baseline: None,
4538 dupes_baseline: None,
4539 max_crap: None,
4540 coverage: None,
4541 coverage_root: None,
4542 gate: AuditGate::NewOnly,
4543 include_entry_exports: false,
4544 runtime_coverage: None,
4545 min_invocations_hot: 100,
4546 };
4547
4548 let result = execute_audit(&opts).expect("audit should execute");
4549 let health = result.health.expect("health should run for changed files");
4550 let timings = health.timings.expect("performance timings should be kept");
4551 assert!(timings.discover_ms.abs() < f64::EPSILON);
4552 assert!(timings.parse_ms.abs() < f64::EPSILON);
4553 assert!(
4557 result.dupes.is_some(),
4558 "dupes should run when changed files exist"
4559 );
4560 }
4561
4562 #[test]
4563 fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
4564 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4568 let root = tmp.path();
4569 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4570 fs::write(
4571 root.join("package.json"),
4572 r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
4573 )
4574 .expect("package.json should be written");
4575 fs::write(
4576 root.join("src/index.ts"),
4577 "import { used } from './used';\nused();\n",
4578 )
4579 .expect("index should be written");
4580 fs::write(
4581 root.join("src/used.ts"),
4582 "export function used() {\n return 1;\n}\n",
4583 )
4584 .expect("used module should be written");
4585
4586 git(root, &["init", "-b", "main"]);
4587 git(root, &["add", "."]);
4588 git(
4589 root,
4590 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4591 );
4592 fs::write(
4593 root.join("src/used.ts"),
4594 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
4595 )
4596 .expect("changed module should be written");
4597
4598 let config_path = None;
4599 let opts = AuditOptions {
4600 root,
4601 config_path: &config_path,
4602 output: OutputFormat::Json,
4603 no_cache: true,
4604 threads: 1,
4605 quiet: true,
4606 changed_since: Some("HEAD"),
4607 production: false,
4608 production_dead_code: Some(true),
4609 production_health: Some(false),
4610 production_dupes: Some(false),
4611 workspace: None,
4612 changed_workspaces: None,
4613 explain: false,
4614 explain_skipped: false,
4615 performance: true,
4616 group_by: None,
4617 dead_code_baseline: None,
4618 health_baseline: None,
4619 dupes_baseline: None,
4620 max_crap: None,
4621 coverage: None,
4622 coverage_root: None,
4623 gate: AuditGate::NewOnly,
4624 include_entry_exports: false,
4625 runtime_coverage: None,
4626 min_invocations_hot: 100,
4627 };
4628
4629 let result = execute_audit(&opts).expect("audit should execute");
4630 assert!(result.dupes.is_some(), "dupes should still run");
4631 }
4632
4633 #[cfg(unix)]
4634 #[test]
4635 fn remap_focus_files_does_not_canonicalize_through_symlinks() {
4636 let tmp = tempfile::TempDir::new().expect("temp dir");
4646 let real = tmp.path().join("real");
4647 let link = tmp.path().join("link");
4648 fs::create_dir_all(&real).expect("real dir");
4649 std::os::unix::fs::symlink(&real, &link).expect("symlink");
4650 let canonical = link.canonicalize().expect("canonicalize symlink");
4654 assert_ne!(link, canonical, "symlink should not equal its target");
4655
4656 let from_root = PathBuf::from("/repo");
4657 let mut focus = FxHashSet::default();
4658 focus.insert(from_root.join("src/foo.ts"));
4659
4660 let remapped = remap_focus_files(&focus, &from_root, &link)
4661 .expect("remap should succeed for in-prefix files");
4662
4663 let expected = link.join("src/foo.ts");
4664 assert!(
4665 remapped.contains(&expected),
4666 "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
4667 );
4668 }
4669
4670 #[test]
4671 fn remap_focus_files_skips_paths_outside_from_root() {
4672 let from_root = PathBuf::from("/repo/apps/web");
4676 let to_root = PathBuf::from("/wt/apps/web");
4677 let mut focus = FxHashSet::default();
4678 focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
4679 focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
4680
4681 let remapped =
4682 remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
4683
4684 assert_eq!(remapped.len(), 1);
4685 assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
4686 }
4687
4688 #[test]
4689 fn remap_focus_files_returns_none_when_no_paths_map() {
4690 let from_root = PathBuf::from("/repo/apps/web");
4691 let to_root = PathBuf::from("/wt/apps/web");
4692 let mut focus = FxHashSet::default();
4693 focus.insert(PathBuf::from("/elsewhere/foo.ts"));
4694
4695 let remapped = remap_focus_files(&focus, &from_root, &to_root);
4696 assert!(
4697 remapped.is_none(),
4698 "remap should return None when no paths can be mapped, falling caller back to full corpus"
4699 );
4700 }
4701
4702 #[test]
4703 fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
4704 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4715 let root_buf = tmp
4724 .path()
4725 .canonicalize()
4726 .expect("temp root should canonicalize");
4727 let root = root_buf.as_path();
4728 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4729 fs::write(
4730 root.join("package.json"),
4731 r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
4732 )
4733 .expect("package.json should be written");
4734 fs::write(
4735 root.join(".fallowrc.json"),
4736 r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
4737 )
4738 .expect("config should be written");
4739
4740 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";
4741 fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
4742 fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
4743
4744 git(root, &["init", "-b", "main"]);
4745 git(root, &["add", "."]);
4746 git(
4747 root,
4748 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4749 );
4750 fs::write(
4753 root.join("src/changed.ts"),
4754 format!("{dup_block}// touched\n"),
4755 )
4756 .expect("changed file should be modified");
4757 git(root, &["add", "."]);
4758 git(
4759 root,
4760 &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
4761 );
4762
4763 let config_path = None;
4764 let opts = AuditOptions {
4765 root,
4766 config_path: &config_path,
4767 output: OutputFormat::Json,
4768 no_cache: true,
4769 threads: 1,
4770 quiet: true,
4771 changed_since: Some("HEAD~1"),
4772 production: false,
4773 production_dead_code: None,
4774 production_health: None,
4775 production_dupes: None,
4776 workspace: None,
4777 changed_workspaces: None,
4778 explain: false,
4779 explain_skipped: false,
4780 performance: false,
4781 group_by: None,
4782 dead_code_baseline: None,
4783 health_baseline: None,
4784 dupes_baseline: None,
4785 max_crap: None,
4786 coverage: None,
4787 coverage_root: None,
4788 gate: AuditGate::NewOnly,
4789 include_entry_exports: false,
4790 runtime_coverage: None,
4791 min_invocations_hot: 100,
4792 };
4793
4794 let result = execute_audit(&opts).expect("audit should execute");
4795 assert!(
4796 result.base_snapshot_skipped,
4797 "comment-only JS/TS diffs should reuse current keys as the base snapshot"
4798 );
4799 let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
4800 assert!(
4801 !dupes_report.clone_groups.is_empty(),
4802 "current run should detect the pre-existing duplicate"
4803 );
4804 assert_eq!(
4805 result.attribution.duplication_introduced, 0,
4806 "pre-existing duplicate must not be classified as introduced; \
4807 attribution = {:?}",
4808 result.attribution
4809 );
4810 assert!(
4811 result.attribution.duplication_inherited > 0,
4812 "pre-existing duplicate must be classified as inherited; \
4813 attribution = {:?}",
4814 result.attribution
4815 );
4816 }
4817
4818 #[test]
4819 fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
4820 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4821 let root = tmp.path();
4822 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4823 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4824 .expect("node_modules config dir should be created");
4825 fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
4826 fs::write(
4827 root.join("package.json"),
4828 r#"{
4829 "name": "audit-react-native-tsconfig-base",
4830 "private": true,
4831 "main": "src/App.tsx",
4832 "dependencies": {
4833 "react-native": "0.80.0"
4834 }
4835 }"#,
4836 )
4837 .expect("package.json should be written");
4838 fs::write(
4839 root.join("tsconfig.json"),
4840 r#"{
4841 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4842 "compilerOptions": {
4843 "baseUrl": ".",
4844 "paths": {
4845 "@/*": ["src/*"]
4846 }
4847 },
4848 "include": ["src/**/*"]
4849 }"#,
4850 )
4851 .expect("tsconfig should be written");
4852 fs::write(
4853 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4854 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4855 )
4856 .expect("react native tsconfig should be written");
4857 fs::write(
4858 root.join("src/App.tsx"),
4859 r#"import { homeTitle } from "@/screens/Home";
4860
4861export function App() {
4862 return homeTitle;
4863}
4864"#,
4865 )
4866 .expect("app should be written");
4867 fs::write(
4868 root.join("src/screens/Home.ts"),
4869 r#"export const homeTitle = "home";
4870"#,
4871 )
4872 .expect("home should be written");
4873
4874 git(root, &["init", "-b", "main"]);
4875 git(root, &["add", "."]);
4876 git(
4877 root,
4878 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4879 );
4880 fs::write(
4881 root.join("src/App.tsx"),
4882 r#"import { homeTitle } from "@/screens/Home";
4883
4884export function App() {
4885 return homeTitle.toUpperCase();
4886}
4887"#,
4888 )
4889 .expect("app should be modified");
4890
4891 let config_path = None;
4892 let opts = AuditOptions {
4893 root,
4894 config_path: &config_path,
4895 output: OutputFormat::Json,
4896 no_cache: true,
4897 threads: 1,
4898 quiet: true,
4899 changed_since: Some("HEAD"),
4900 production: false,
4901 production_dead_code: None,
4902 production_health: None,
4903 production_dupes: None,
4904 workspace: None,
4905 changed_workspaces: None,
4906 explain: false,
4907 explain_skipped: false,
4908 performance: false,
4909 group_by: None,
4910 dead_code_baseline: None,
4911 health_baseline: None,
4912 dupes_baseline: None,
4913 max_crap: None,
4914 coverage: None,
4915 coverage_root: None,
4916 gate: AuditGate::NewOnly,
4917 include_entry_exports: false,
4918 runtime_coverage: None,
4919 min_invocations_hot: 100,
4920 };
4921
4922 let result = execute_audit(&opts).expect("audit should execute");
4923 assert!(
4924 !result.base_snapshot_skipped,
4925 "source diffs should run a real base snapshot"
4926 );
4927 let base = result
4928 .base_snapshot
4929 .as_ref()
4930 .expect("base snapshot should run");
4931 assert!(
4932 !base
4933 .dead_code
4934 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
4935 "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
4936 base.dead_code
4937 );
4938 assert!(
4939 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
4940 "alias target should stay reachable in the base worktree: {:?}",
4941 base.dead_code
4942 );
4943 let check = result.check.as_ref().expect("dead-code audit should run");
4944 assert!(
4945 check.results.unresolved_imports.is_empty(),
4946 "HEAD audit should also resolve @/* aliases: {:?}",
4947 check.results.unresolved_imports
4948 );
4949 }
4950
4951 #[test]
4952 fn audit_base_preserves_subdirectory_root_resolution() {
4953 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4954 let repo = tmp.path().join("repo");
4955 let root = repo.join("apps/mobile");
4956 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4957 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4958 .expect("node_modules config dir should be created");
4959 fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
4960 .expect("gitignore should be written");
4961 fs::write(
4962 root.join("package.json"),
4963 r#"{
4964 "name": "audit-subdir-react-native-tsconfig-base",
4965 "private": true,
4966 "main": "src/App.tsx",
4967 "dependencies": {
4968 "react-native": "0.80.0"
4969 }
4970 }"#,
4971 )
4972 .expect("package.json should be written");
4973 fs::write(
4974 root.join("tsconfig.json"),
4975 r#"{
4976 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4977 "compilerOptions": {
4978 "baseUrl": ".",
4979 "paths": {
4980 "@/*": ["src/*"]
4981 }
4982 },
4983 "include": ["src/**/*"]
4984 }"#,
4985 )
4986 .expect("tsconfig should be written");
4987 fs::write(
4988 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4989 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4990 )
4991 .expect("react native tsconfig should be written");
4992 fs::write(
4993 root.join("src/App.tsx"),
4994 r#"import { homeTitle } from "@/screens/Home";
4995
4996export function App() {
4997 return homeTitle;
4998}
4999"#,
5000 )
5001 .expect("app should be written");
5002 fs::write(
5003 root.join("src/screens/Home.ts"),
5004 r#"export const homeTitle = "home";
5005"#,
5006 )
5007 .expect("home should be written");
5008
5009 git(&repo, &["init", "-b", "main"]);
5010 git(&repo, &["add", "."]);
5011 git(
5012 &repo,
5013 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5014 );
5015 fs::write(
5016 root.join("src/App.tsx"),
5017 r#"import { homeTitle } from "@/screens/Home";
5018
5019export function App() {
5020 return homeTitle.toUpperCase();
5021}
5022"#,
5023 )
5024 .expect("app should be modified");
5025
5026 let config_path = None;
5027 let opts = AuditOptions {
5028 root: &root,
5029 config_path: &config_path,
5030 output: OutputFormat::Json,
5031 no_cache: true,
5032 threads: 1,
5033 quiet: true,
5034 changed_since: Some("HEAD"),
5035 production: false,
5036 production_dead_code: None,
5037 production_health: None,
5038 production_dupes: None,
5039 workspace: None,
5040 changed_workspaces: None,
5041 explain: false,
5042 explain_skipped: false,
5043 performance: false,
5044 group_by: None,
5045 dead_code_baseline: None,
5046 health_baseline: None,
5047 dupes_baseline: None,
5048 max_crap: None,
5049 coverage: None,
5050 coverage_root: None,
5051 gate: AuditGate::NewOnly,
5052 include_entry_exports: false,
5053 runtime_coverage: None,
5054 min_invocations_hot: 100,
5055 };
5056
5057 let result = execute_audit(&opts).expect("audit should execute");
5058 assert!(
5059 !result.base_snapshot_skipped,
5060 "source diffs should run a real base snapshot"
5061 );
5062 let base = result
5063 .base_snapshot
5064 .as_ref()
5065 .expect("base snapshot should run");
5066 assert!(
5067 !base
5068 .dead_code
5069 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
5070 "base audit should analyze from the app subdirectory, not the repo root: {:?}",
5071 base.dead_code
5072 );
5073 assert!(
5074 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
5075 "subdirectory base audit should keep alias targets reachable: {:?}",
5076 base.dead_code
5077 );
5078 }
5079
5080 #[test]
5081 fn audit_base_uses_new_explicit_config_without_hard_failure() {
5082 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5083 let root = tmp.path();
5084 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5085 fs::write(
5086 root.join("package.json"),
5087 r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
5088 )
5089 .expect("package.json should be written");
5090 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5091 .expect("index should be written");
5092
5093 git(root, &["init", "-b", "main"]);
5094 git(root, &["add", "."]);
5095 git(
5096 root,
5097 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5098 );
5099
5100 let explicit_config = root.join(".fallowrc.json");
5101 fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
5102 .expect("new config should be written");
5103 fs::write(root.join("src/index.ts"), "export const used = 2;\n")
5104 .expect("index should be modified");
5105
5106 let config_path = Some(explicit_config);
5107 let opts = AuditOptions {
5108 root,
5109 config_path: &config_path,
5110 output: OutputFormat::Json,
5111 no_cache: true,
5112 threads: 1,
5113 quiet: true,
5114 changed_since: Some("HEAD"),
5115 production: false,
5116 production_dead_code: None,
5117 production_health: None,
5118 production_dupes: None,
5119 workspace: None,
5120 changed_workspaces: None,
5121 explain: false,
5122 explain_skipped: false,
5123 performance: false,
5124 group_by: None,
5125 dead_code_baseline: None,
5126 health_baseline: None,
5127 dupes_baseline: None,
5128 max_crap: None,
5129 coverage: None,
5130 coverage_root: None,
5131 gate: AuditGate::NewOnly,
5132 include_entry_exports: false,
5133 runtime_coverage: None,
5134 min_invocations_hot: 100,
5135 };
5136
5137 let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
5138 assert!(
5139 result.base_snapshot.is_some(),
5140 "base snapshot should use the current explicit config even when the base commit lacks it"
5141 );
5142 }
5143
5144 #[test]
5145 fn audit_base_uses_current_discovered_config_for_attribution() {
5146 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5147 let root = tmp.path();
5148 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5149 fs::write(
5150 root.join("package.json"),
5151 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5152 )
5153 .expect("package.json should be written");
5154 fs::write(
5155 root.join(".fallowrc.json"),
5156 r#"{"rules":{"unused-dependencies":"off"}}"#,
5157 )
5158 .expect("base config should be written");
5159 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5160 .expect("index should be written");
5161
5162 git(root, &["init", "-b", "main"]);
5163 git(root, &["add", "."]);
5164 git(
5165 root,
5166 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5167 );
5168
5169 fs::write(
5170 root.join(".fallowrc.json"),
5171 r#"{"rules":{"unused-dependencies":"error"}}"#,
5172 )
5173 .expect("current config should be written");
5174 fs::write(
5175 root.join("package.json"),
5176 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5177 )
5178 .expect("package.json should be touched");
5179
5180 let config_path = None;
5181 let opts = AuditOptions {
5182 root,
5183 config_path: &config_path,
5184 output: OutputFormat::Json,
5185 no_cache: true,
5186 threads: 1,
5187 quiet: true,
5188 changed_since: Some("HEAD"),
5189 production: false,
5190 production_dead_code: None,
5191 production_health: None,
5192 production_dupes: None,
5193 workspace: None,
5194 changed_workspaces: None,
5195 explain: false,
5196 explain_skipped: false,
5197 performance: false,
5198 group_by: None,
5199 dead_code_baseline: None,
5200 health_baseline: None,
5201 dupes_baseline: None,
5202 max_crap: None,
5203 coverage: None,
5204 coverage_root: None,
5205 gate: AuditGate::NewOnly,
5206 include_entry_exports: false,
5207 runtime_coverage: None,
5208 min_invocations_hot: 100,
5209 };
5210
5211 let result = execute_audit(&opts).expect("audit should execute");
5212 assert_eq!(
5213 result.attribution.dead_code_introduced, 0,
5214 "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
5215 result.attribution
5216 );
5217 assert!(
5218 result.attribution.dead_code_inherited > 0,
5219 "pre-existing changed-file findings should be classified as inherited: {:?}",
5220 result.attribution
5221 );
5222 }
5223
5224 #[test]
5225 fn audit_base_current_config_attribution_survives_cache_hit() {
5226 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5227 let root = tmp.path();
5228 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5229 fs::write(
5230 root.join("package.json"),
5231 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5232 )
5233 .expect("package.json should be written");
5234 fs::write(
5235 root.join(".fallowrc.json"),
5236 r#"{"rules":{"unused-dependencies":"off"}}"#,
5237 )
5238 .expect("base config should be written");
5239 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5240 .expect("index should be written");
5241
5242 git(root, &["init", "-b", "main"]);
5243 git(root, &["add", "."]);
5244 git(
5245 root,
5246 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5247 );
5248
5249 fs::write(
5250 root.join(".fallowrc.json"),
5251 r#"{"rules":{"unused-dependencies":"error"}}"#,
5252 )
5253 .expect("current config should be written");
5254 fs::write(
5255 root.join("package.json"),
5256 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5257 )
5258 .expect("package.json should be touched");
5259
5260 let config_path = None;
5261 let opts = AuditOptions {
5262 root,
5263 config_path: &config_path,
5264 output: OutputFormat::Json,
5265 no_cache: false,
5266 threads: 1,
5267 quiet: true,
5268 changed_since: Some("HEAD"),
5269 production: false,
5270 production_dead_code: None,
5271 production_health: None,
5272 production_dupes: None,
5273 workspace: None,
5274 changed_workspaces: None,
5275 explain: false,
5276 explain_skipped: false,
5277 performance: false,
5278 group_by: None,
5279 dead_code_baseline: None,
5280 health_baseline: None,
5281 dupes_baseline: None,
5282 max_crap: None,
5283 coverage: None,
5284 coverage_root: None,
5285 gate: AuditGate::NewOnly,
5286 include_entry_exports: false,
5287 runtime_coverage: None,
5288 min_invocations_hot: 100,
5289 };
5290
5291 let first = execute_audit(&opts).expect("first audit should execute");
5292 assert_eq!(
5293 first.attribution.dead_code_introduced, 0,
5294 "first audit should classify pre-existing findings as inherited: {:?}",
5295 first.attribution
5296 );
5297
5298 let changed_files =
5299 crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
5300 let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
5301 .expect("cache key should compute")
5302 .expect("cache key should exist");
5303 assert!(
5304 load_cached_base_snapshot(&opts, &key).is_some(),
5305 "first audit should store a reusable base snapshot"
5306 );
5307
5308 let second = execute_audit(&opts).expect("second audit should execute");
5309 assert_eq!(
5310 second.attribution.dead_code_introduced, 0,
5311 "cache hit should keep current-config attribution stable: {:?}",
5312 second.attribution
5313 );
5314 assert!(
5315 second.attribution.dead_code_inherited > 0,
5316 "cache hit should preserve inherited base findings: {:?}",
5317 second.attribution
5318 );
5319 }
5320
5321 #[test]
5322 fn audit_dupes_only_materializes_groups_touching_changed_files() {
5323 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5324 let root_path = tmp
5325 .path()
5326 .canonicalize()
5327 .expect("temp root should canonicalize");
5328 let root = root_path.as_path();
5329 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5330 fs::write(
5331 root.join("package.json"),
5332 r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
5333 )
5334 .expect("package.json should be written");
5335 fs::write(
5336 root.join(".fallowrc.json"),
5337 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
5338 )
5339 .expect("config should be written");
5340
5341 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";
5342 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";
5343 fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
5344 fs::write(root.join("src/focused-copy.ts"), focused_code)
5345 .expect("focused copy should be written");
5346 fs::write(root.join("src/untouched-a.ts"), untouched_code)
5347 .expect("untouched a should be written");
5348 fs::write(root.join("src/untouched-b.ts"), untouched_code)
5349 .expect("untouched b should be written");
5350
5351 git(root, &["init", "-b", "main"]);
5352 git(root, &["add", "."]);
5353 git(
5354 root,
5355 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5356 );
5357 fs::write(
5358 root.join("src/changed.ts"),
5359 format!("{focused_code}export const changedMarker = true;\n"),
5360 )
5361 .expect("changed file should be modified");
5362
5363 let config_path = None;
5364 let opts = AuditOptions {
5365 root,
5366 config_path: &config_path,
5367 output: OutputFormat::Json,
5368 no_cache: true,
5369 threads: 1,
5370 quiet: true,
5371 changed_since: Some("HEAD"),
5372 production: false,
5373 production_dead_code: None,
5374 production_health: None,
5375 production_dupes: None,
5376 workspace: None,
5377 changed_workspaces: None,
5378 explain: false,
5379 explain_skipped: false,
5380 performance: false,
5381 group_by: None,
5382 dead_code_baseline: None,
5383 health_baseline: None,
5384 dupes_baseline: None,
5385 max_crap: None,
5386 coverage: None,
5387 coverage_root: None,
5388 gate: AuditGate::All,
5389 include_entry_exports: false,
5390 runtime_coverage: None,
5391 min_invocations_hot: 100,
5392 };
5393
5394 let result = execute_audit(&opts).expect("audit should execute");
5395 let dupes = result.dupes.expect("dupes should run");
5396 let changed_path = root.join("src/changed.ts");
5397
5398 assert!(
5399 !dupes.report.clone_groups.is_empty(),
5400 "changed file should still match unchanged duplicate code"
5401 );
5402 assert!(dupes.report.clone_groups.iter().all(|group| {
5403 group
5404 .instances
5405 .iter()
5406 .any(|instance| instance.file == changed_path)
5407 }));
5408 }
5409}