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