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