1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitCode};
4use std::time::{Duration, Instant};
5
6use fallow_config::{AuditGate, OutputFormat};
7use fallow_core::git_env::clear_ambient_git_env;
8use rustc_hash::{FxHashMap, FxHashSet};
9use xxhash_rust::xxh3::xxh3_64;
10
11use crate::base_worktree::{
12 BaseWorktree, git_rev_parse, git_toplevel, resolve_cache_max_age, sweep_old_reusable_caches,
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};
18
19const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 3;
20const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[serde(rename_all = "snake_case")]
26pub enum AuditVerdict {
27 Pass,
29 Warn,
31 Fail,
33}
34
35#[derive(Debug, Clone, serde::Serialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38pub struct AuditSummary {
39 pub dead_code_issues: usize,
40 pub dead_code_has_errors: bool,
41 pub complexity_findings: usize,
42 pub max_cyclomatic: Option<u16>,
43 pub duplication_clone_groups: usize,
44}
45
46#[derive(Debug, Default, Clone, serde::Serialize)]
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49pub struct AuditAttribution {
50 pub gate: AuditGate,
51 pub dead_code_introduced: usize,
52 pub dead_code_inherited: usize,
53 pub complexity_introduced: usize,
54 pub complexity_inherited: usize,
55 pub duplication_introduced: usize,
56 pub duplication_inherited: usize,
57}
58
59pub struct AuditResult {
61 pub verdict: AuditVerdict,
62 pub summary: AuditSummary,
63 pub attribution: AuditAttribution,
64 pub base_snapshot: Option<AuditKeySnapshot>,
69 pub base_snapshot_skipped: bool,
70 pub changed_files_count: usize,
71 pub changed_files: Vec<PathBuf>,
75 pub base_ref: String,
76 pub base_description: Option<String>,
81 pub head_sha: Option<String>,
82 pub output: OutputFormat,
83 pub performance: bool,
84 pub check: Option<CheckResult>,
85 pub dupes: Option<DupesResult>,
86 pub health: Option<HealthResult>,
87 pub elapsed: Duration,
88 pub review_deltas: Option<crate::audit_brief::ReviewDeltas>,
92 pub weakening_signals: Vec<weakening::WeakeningSignal>,
93 pub routing: Option<routing::RoutingFacts>,
94 pub decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
98 pub graph_snapshot_hash: Option<String>,
105 pub change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
111}
112
113pub struct AuditOptions<'a> {
114 pub root: &'a std::path::Path,
115 pub config_path: &'a Option<std::path::PathBuf>,
116 pub cache_dir: &'a std::path::Path,
117 pub output: OutputFormat,
118 pub no_cache: bool,
119 pub threads: usize,
120 pub quiet: bool,
121 pub changed_since: Option<&'a str>,
122 pub production: bool,
123 pub production_dead_code: Option<bool>,
124 pub production_health: Option<bool>,
125 pub production_dupes: Option<bool>,
126 pub workspace: Option<&'a [String]>,
127 pub changed_workspaces: Option<&'a str>,
128 pub explain: bool,
129 pub explain_skipped: bool,
130 pub performance: bool,
131 pub group_by: Option<crate::GroupBy>,
132 pub dead_code_baseline: Option<&'a std::path::Path>,
134 pub health_baseline: Option<&'a std::path::Path>,
136 pub dupes_baseline: Option<&'a std::path::Path>,
138 pub max_crap: Option<f64>,
141 pub coverage: Option<&'a std::path::Path>,
143 pub coverage_root: Option<&'a std::path::Path>,
145 pub gate: AuditGate,
146 pub include_entry_exports: bool,
148 pub runtime_coverage: Option<&'a std::path::Path>,
154 pub min_invocations_hot: u64,
156 pub brief: bool,
161 pub max_decisions: usize,
165 pub walkthrough_guide: bool,
168 pub walkthrough_file: Option<&'a std::path::Path>,
171 pub show_deprioritized: bool,
176}
177
178struct DetectedBase {
181 git_ref: String,
184 description: String,
187}
188
189fn git_stdout(root: &std::path::Path, args: &[&str]) -> Option<String> {
192 let mut command = std::process::Command::new("git");
193 command.args(args).current_dir(root);
194 clear_ambient_git_env(&mut command);
195 let output = command.output().ok()?;
196 if !output.status.success() {
197 return None;
198 }
199 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
200 if trimmed.is_empty() {
201 None
202 } else {
203 Some(trimmed)
204 }
205}
206
207fn git_ref_exists(root: &std::path::Path, git_ref: &str) -> bool {
209 git_stdout(root, &["rev-parse", "--verify", "--quiet", git_ref]).is_some()
210}
211
212fn git_upstream_ref(root: &std::path::Path) -> Option<String> {
215 git_stdout(
216 root,
217 &[
218 "rev-parse",
219 "--abbrev-ref",
220 "--symbolic-full-name",
221 "@{upstream}",
222 ],
223 )
224}
225
226fn git_merge_base(root: &std::path::Path, a: &str, b: &str) -> Option<String> {
229 git_stdout(root, &["merge-base", a, b])
230}
231
232fn detect_remote_default_ref(root: &std::path::Path) -> Option<String> {
236 if let Some(full_ref) = git_stdout(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
237 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
238 {
239 return Some(format!("origin/{branch}"));
240 }
241 for candidate in ["origin/main", "origin/master"] {
242 if git_ref_exists(root, candidate) {
243 return Some(candidate.to_string());
244 }
245 }
246 None
247}
248
249fn auto_detect_base_ref(root: &std::path::Path) -> Option<DetectedBase> {
271 if let Some(upstream) = git_upstream_ref(root) {
272 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
273 return Some(DetectedBase {
274 git_ref: sha,
275 description: format!("merge-base with {upstream}"),
276 });
277 }
278 return Some(DetectedBase {
281 description: format!("{upstream} (tip)"),
282 git_ref: upstream,
283 });
284 }
285
286 if let Some(remote_ref) = detect_remote_default_ref(root) {
287 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
288 return Some(DetectedBase {
289 git_ref: sha,
290 description: format!("merge-base with {remote_ref}"),
291 });
292 }
293 return Some(DetectedBase {
294 description: format!("{remote_ref} (tip)"),
295 git_ref: remote_ref,
296 });
297 }
298
299 for candidate in ["main", "master"] {
300 if git_ref_exists(root, candidate) {
301 return Some(DetectedBase {
302 git_ref: candidate.to_string(),
303 description: format!("local {candidate}"),
304 });
305 }
306 }
307
308 None
309}
310
311fn get_head_sha(root: &std::path::Path) -> Option<String> {
313 let mut command = std::process::Command::new("git");
314 command
315 .args(["rev-parse", "--short", "HEAD"])
316 .current_dir(root);
317 clear_ambient_git_env(&mut command);
318 let output = command.output().ok()?;
319 if output.status.success() {
320 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
321 } else {
322 None
323 }
324}
325
326fn compute_verdict(
327 check: Option<&CheckResult>,
328 dupes: Option<&DupesResult>,
329 health: Option<&HealthResult>,
330) -> AuditVerdict {
331 let mut has_errors = false;
332 let mut has_warnings = false;
333
334 if let Some(result) = check {
335 if crate::check::has_error_severity_issues(
336 &result.results,
337 &result.config.rules,
338 Some(&result.config),
339 ) {
340 has_errors = true;
341 } else if result.results.total_issues() > 0 {
342 has_warnings = true;
343 }
344 }
345
346 if let Some(result) = health
347 && !result.report.findings.is_empty()
348 {
349 has_errors = true;
350 }
351
352 if let Some(result) = dupes
353 && !result.report.clone_groups.is_empty()
354 {
355 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
356 has_errors = true;
357 } else {
358 has_warnings = true;
359 }
360 }
361
362 if has_errors {
363 AuditVerdict::Fail
364 } else if has_warnings {
365 AuditVerdict::Warn
366 } else {
367 AuditVerdict::Pass
368 }
369}
370
371fn build_summary(
372 check: Option<&CheckResult>,
373 dupes: Option<&DupesResult>,
374 health: Option<&HealthResult>,
375) -> AuditSummary {
376 let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
377 let dead_code_has_errors = check.is_some_and(|r| {
378 crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
379 });
380 let complexity_findings = health.map_or(0, |r| r.report.findings.len());
381 let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
382 let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
383
384 AuditSummary {
385 dead_code_issues,
386 dead_code_has_errors,
387 complexity_findings,
388 max_cyclomatic,
389 duplication_clone_groups,
390 }
391}
392
393fn compute_audit_attribution(
394 check: Option<&CheckResult>,
395 dupes: Option<&DupesResult>,
396 health: Option<&HealthResult>,
397 base: Option<&AuditKeySnapshot>,
398 gate: AuditGate,
399) -> AuditAttribution {
400 let dead_code = check
401 .map(|r| {
402 count_introduced(
403 &dead_code_keys(&r.results, &r.config.root),
404 base.map(|b| &b.dead_code),
405 )
406 })
407 .unwrap_or_default();
408 let complexity = health
409 .map(|r| {
410 count_introduced(
411 &health_keys(&r.report, &r.config.root),
412 base.map(|b| &b.health),
413 )
414 })
415 .unwrap_or_default();
416 let duplication = dupes
417 .map(|r| {
418 count_introduced(
419 &dupes_keys(&r.report, &r.config.root),
420 base.map(|b| &b.dupes),
421 )
422 })
423 .unwrap_or_default();
424
425 AuditAttribution {
426 gate,
427 dead_code_introduced: dead_code.0,
428 dead_code_inherited: dead_code.1,
429 complexity_introduced: complexity.0,
430 complexity_inherited: complexity.1,
431 duplication_introduced: duplication.0,
432 duplication_inherited: duplication.1,
433 }
434}
435
436fn compute_introduced_verdict(
437 check: Option<&CheckResult>,
438 dupes: Option<&DupesResult>,
439 health: Option<&HealthResult>,
440 base: Option<&AuditKeySnapshot>,
441) -> AuditVerdict {
442 let mut has_errors = false;
443 let mut has_warnings = false;
444
445 if let Some(result) = check {
446 let (errors, warnings) = introduced_check_verdict(result, base);
447 has_errors |= errors;
448 has_warnings |= warnings;
449 }
450 if let Some(result) = health {
451 has_errors |= introduced_health_has_errors(result, base);
452 }
453 if let Some(result) = dupes {
454 let (errors, warnings) = introduced_dupes_verdict(result, base);
455 has_errors |= errors;
456 has_warnings |= warnings;
457 }
458
459 if has_errors {
460 AuditVerdict::Fail
461 } else if has_warnings {
462 AuditVerdict::Warn
463 } else {
464 AuditVerdict::Pass
465 }
466}
467
468fn introduced_check_verdict(result: &CheckResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
470 let base_keys = base.map(|b| &b.dead_code);
471 let mut introduced = result.results.clone();
472 retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
473 if crate::check::has_error_severity_issues(
474 &introduced,
475 &result.config.rules,
476 Some(&result.config),
477 ) {
478 (true, false)
479 } else if introduced.total_issues() > 0 {
480 (false, true)
481 } else {
482 (false, false)
483 }
484}
485
486fn introduced_health_has_errors(result: &HealthResult, base: Option<&AuditKeySnapshot>) -> bool {
488 let base_keys = base.map(|b| &b.health);
489 result.report.findings.iter().any(|finding| {
490 !base_keys
491 .is_some_and(|keys| keys.contains(&health_finding_key(finding, &result.config.root)))
492 })
493}
494
495fn introduced_dupes_verdict(result: &DupesResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
497 let base_keys = base.map(|b| &b.dupes);
498 let introduced = result
499 .report
500 .clone_groups
501 .iter()
502 .filter(|group| {
503 !base_keys
504 .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
505 })
506 .count();
507 if introduced == 0 {
508 return (false, false);
509 }
510 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
511 (true, false)
512 } else {
513 (false, true)
514 }
515}
516
517pub struct AuditKeySnapshot {
518 dead_code: FxHashSet<String>,
519 health: FxHashSet<String>,
520 dupes: FxHashSet<String>,
521 boundary_edges: FxHashSet<String>,
525 cycles: FxHashSet<String>,
527 public_api: FxHashSet<String>,
530}
531
532struct AuditBaseSnapshotCacheKey {
533 hash: u64,
534 base_sha: String,
535}
536
537#[derive(bitcode::Encode, bitcode::Decode)]
538struct CachedAuditKeySnapshot {
539 version: u8,
540 cli_version: String,
541 key_hash: u64,
542 base_sha: String,
543 dead_code: Vec<String>,
544 health: Vec<String>,
545 dupes: Vec<String>,
546 boundary_edges: Vec<String>,
547 cycles: Vec<String>,
548 public_api: Vec<String>,
549}
550
551fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
552 let Some(base) = base else {
553 return (0, 0);
554 };
555 keys.iter().fold((0, 0), |(introduced, inherited), key| {
556 if base.contains(key) {
557 (introduced, inherited + 1)
558 } else {
559 (introduced + 1, inherited)
560 }
561 })
562}
563
564fn sorted_keys(keys: &FxHashSet<String>) -> Vec<String> {
565 let mut keys: Vec<String> = keys.iter().cloned().collect();
566 keys.sort_unstable();
567 keys
568}
569
570fn snapshot_from_cached(cached: CachedAuditKeySnapshot) -> AuditKeySnapshot {
571 AuditKeySnapshot {
572 dead_code: cached.dead_code.into_iter().collect(),
573 health: cached.health.into_iter().collect(),
574 dupes: cached.dupes.into_iter().collect(),
575 boundary_edges: cached.boundary_edges.into_iter().collect(),
576 cycles: cached.cycles.into_iter().collect(),
577 public_api: cached.public_api.into_iter().collect(),
578 }
579}
580
581fn cached_from_snapshot(
582 key: &AuditBaseSnapshotCacheKey,
583 snapshot: &AuditKeySnapshot,
584) -> CachedAuditKeySnapshot {
585 CachedAuditKeySnapshot {
586 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
587 cli_version: env!("CARGO_PKG_VERSION").to_string(),
588 key_hash: key.hash,
589 base_sha: key.base_sha.clone(),
590 dead_code: sorted_keys(&snapshot.dead_code),
591 health: sorted_keys(&snapshot.health),
592 dupes: sorted_keys(&snapshot.dupes),
593 boundary_edges: sorted_keys(&snapshot.boundary_edges),
594 cycles: sorted_keys(&snapshot.cycles),
595 public_api: sorted_keys(&snapshot.public_api),
596 }
597}
598
599fn audit_base_snapshot_cache_dir(cache_dir: &Path) -> PathBuf {
600 cache_dir
601 .join("cache")
602 .join(format!("audit-base-v{AUDIT_BASE_SNAPSHOT_CACHE_VERSION}"))
603}
604
605fn audit_base_snapshot_cache_file(cache_dir: &Path, key: &AuditBaseSnapshotCacheKey) -> PathBuf {
606 audit_base_snapshot_cache_dir(cache_dir).join(format!("{:016x}.bin", key.hash))
607}
608
609fn ensure_audit_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
610 std::fs::create_dir_all(dir)?;
611 let gitignore = dir.join(".gitignore");
612 if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
613 std::fs::write(gitignore, "*\n")?;
614 }
615 Ok(())
616}
617
618fn load_cached_base_snapshot(
619 opts: &AuditOptions<'_>,
620 key: &AuditBaseSnapshotCacheKey,
621) -> Option<AuditKeySnapshot> {
622 let path = audit_base_snapshot_cache_file(opts.cache_dir, key);
623 let data = std::fs::read(path).ok()?;
624 if data.len() > MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE {
625 return None;
626 }
627 let cached: CachedAuditKeySnapshot = bitcode::decode(&data).ok()?;
628 if cached.version != AUDIT_BASE_SNAPSHOT_CACHE_VERSION
629 || cached.cli_version != env!("CARGO_PKG_VERSION")
630 || cached.key_hash != key.hash
631 || cached.base_sha != key.base_sha
632 {
633 return None;
634 }
635 Some(snapshot_from_cached(cached))
636}
637
638fn save_cached_base_snapshot(
639 opts: &AuditOptions<'_>,
640 key: &AuditBaseSnapshotCacheKey,
641 snapshot: &AuditKeySnapshot,
642) {
643 let dir = audit_base_snapshot_cache_dir(opts.cache_dir);
644 if ensure_audit_base_snapshot_cache_dir(&dir).is_err() {
645 return;
646 }
647 let data = bitcode::encode(&cached_from_snapshot(key, snapshot));
648 let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
649 return;
650 };
651 if tmp.write_all(&data).is_err() {
652 return;
653 }
654 let _ = tmp.persist(audit_base_snapshot_cache_file(opts.cache_dir, key));
655}
656
657fn ambient_git_env_hint() -> Option<String> {
662 use fallow_core::git_env::AMBIENT_GIT_ENV_VARS;
663 for var in AMBIENT_GIT_ENV_VARS {
664 if let Ok(value) = std::env::var(var)
665 && !value.is_empty()
666 {
667 return Some(format!(
668 "{var}={value} is set in the environment; if fallow is being \
669invoked from a git hook this can interfere with worktree operations. Re-run \
670with `env -u {var} fallow audit` to confirm."
671 ));
672 }
673 }
674 None
675}
676
677fn normalized_changed_files(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
678 let git_root = git_toplevel(root);
679 let mut files: Vec<String> = changed_files
680 .iter()
681 .map(|path| {
682 git_root
683 .as_ref()
684 .and_then(|root| path.strip_prefix(root).ok())
685 .unwrap_or(path)
686 .to_string_lossy()
687 .replace('\\', "/")
688 })
689 .collect();
690 files.sort_unstable();
691 files
692}
693
694fn config_file_fingerprint(opts: &AuditOptions<'_>) -> Result<serde_json::Value, ExitCode> {
695 let loaded = if let Some(path) = opts.config_path {
696 let config = fallow_config::FallowConfig::load(path).map_err(|e| {
697 emit_error(
698 &format!("failed to load config '{}': {e}", path.display()),
699 2,
700 opts.output,
701 )
702 })?;
703 Some((config, path.clone()))
704 } else {
705 fallow_config::FallowConfig::find_and_load(opts.root)
706 .map_err(|e| emit_error(&e, 2, opts.output))?
707 };
708
709 let Some((config, path)) = loaded else {
710 return Ok(serde_json::json!({
711 "path": null,
712 "resolved_hash": null,
713 }));
714 };
715 let bytes = serde_json::to_vec(&config).map_err(|e| {
716 emit_error(
717 &format!("failed to serialize resolved config for audit cache key: {e}"),
718 2,
719 opts.output,
720 )
721 })?;
722 Ok(serde_json::json!({
723 "path": path.to_string_lossy(),
724 "resolved_hash": format!("{:016x}", xxh3_64(&bytes)),
725 }))
726}
727
728fn coverage_file_fingerprint(path: &Path, project_root: &Path) -> serde_json::Value {
729 let resolved = crate::health::scoring::resolve_relative_to_root(path, Some(project_root));
730 let file_path = if resolved.is_dir() {
731 resolved.join("coverage-final.json")
732 } else {
733 resolved
734 };
735 match std::fs::read(&file_path) {
736 Ok(bytes) => serde_json::json!({
737 "path": path.to_string_lossy(),
738 "resolved_path": file_path.to_string_lossy(),
739 "content_hash": format!("{:016x}", xxh3_64(&bytes)),
740 "len": bytes.len(),
741 }),
742 Err(err) => serde_json::json!({
743 "path": path.to_string_lossy(),
744 "resolved_path": file_path.to_string_lossy(),
745 "error": err.kind().to_string(),
746 }),
747 }
748}
749
750fn audit_base_snapshot_cache_key(
751 opts: &AuditOptions<'_>,
752 base_ref: &str,
753 changed_files: &FxHashSet<PathBuf>,
754) -> Result<Option<AuditBaseSnapshotCacheKey>, ExitCode> {
755 if opts.no_cache {
756 return Ok(None);
757 }
758 let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
759 return Ok(None);
760 };
761 let config_file = config_file_fingerprint(opts)?;
762 let coverage_file = opts
763 .coverage
764 .map(|p| coverage_file_fingerprint(p, opts.root));
765 let payload = serde_json::json!({
766 "cache_version": AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
767 "cli_version": env!("CARGO_PKG_VERSION"),
768 "base_sha": base_sha,
769 "config_file": config_file,
770 "changed_files": normalized_changed_files(opts.root, changed_files),
771 "production": opts.production,
772 "production_dead_code": opts.production_dead_code,
773 "production_health": opts.production_health,
774 "production_dupes": opts.production_dupes,
775 "workspace": opts.workspace,
776 "changed_workspaces": opts.changed_workspaces,
777 "group_by": opts.group_by.map(|g| format!("{g:?}")),
778 "include_entry_exports": opts.include_entry_exports,
779 "max_crap": opts.max_crap,
780 "coverage": coverage_file,
781 "coverage_root": opts.coverage_root.map(|p| p.to_string_lossy().to_string()),
782 "dead_code_baseline": opts.dead_code_baseline.map(|p| p.to_string_lossy().to_string()),
783 "health_baseline": opts.health_baseline.map(|p| p.to_string_lossy().to_string()),
784 "dupes_baseline": opts.dupes_baseline.map(|p| p.to_string_lossy().to_string()),
785 });
786 let bytes = serde_json::to_vec(&payload).map_err(|e| {
787 emit_error(
788 &format!("failed to build audit cache key: {e}"),
789 2,
790 opts.output,
791 )
792 })?;
793 Ok(Some(AuditBaseSnapshotCacheKey {
794 hash: xxh3_64(&bytes),
795 base_sha,
796 }))
797}
798
799fn compute_base_snapshot(
800 opts: &AuditOptions<'_>,
801 base_ref: &str,
802 changed_files: &FxHashSet<PathBuf>,
803 base_sha: Option<&str>,
804) -> Result<AuditKeySnapshot, ExitCode> {
805 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
806 use std::fmt::Write as _;
807 let mut message =
808 format!("could not create a temporary worktree for base ref '{base_ref}'");
809 if let Some(hint) = ambient_git_env_hint() {
810 let _ = write!(message, "\n hint: {hint}");
811 }
812 return Err(emit_error(&message, 2, opts.output));
813 };
814 let base_root = base_analysis_root(opts.root, worktree.path());
815 let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
816 let current_config_path = opts
817 .config_path
818 .clone()
819 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
820 let base_opts =
821 build_base_audit_options(opts, &base_root, ¤t_config_path, &base_cache_dir);
822
823 let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
824 let check_production = opts.production_dead_code.unwrap_or(opts.production);
825 let health_production = opts.production_health.unwrap_or(opts.production);
826 let share_dead_code_parse_with_health = check_production == health_production;
827
828 let (check_res, dupes_res) = rayon::join(
829 || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
830 || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
831 );
832 let mut check = check_res?;
833 let dupes = dupes_res?;
834 let base_public_api = if opts.brief {
838 public_api_keys_from_check(check.as_ref(), &base_root)
839 } else {
840 FxHashSet::default()
841 };
842 let shared_parse = if share_dead_code_parse_with_health {
843 check.as_mut().and_then(|r| r.shared_parse.take())
844 } else {
845 None
846 };
847 let health = run_audit_health(&base_opts, None, shared_parse)?;
848 if let Some(ref mut check) = check {
849 check.shared_parse = None;
850 }
851
852 Ok(snapshot_from_results(
853 check.as_ref(),
854 dupes.as_ref(),
855 health.as_ref(),
856 base_public_api,
857 ))
858}
859
860fn snapshot_from_results(
866 check: Option<&CheckResult>,
867 dupes: Option<&DupesResult>,
868 health: Option<&HealthResult>,
869 public_api: FxHashSet<String>,
870) -> AuditKeySnapshot {
871 let (boundary_edges, cycles) = check.map_or_else(
872 || (FxHashSet::default(), FxHashSet::default()),
873 |r| {
874 (
875 review_deltas::boundary_edge_keys(&r.results.boundary_violations),
876 review_deltas::cycle_keys(&r.results.circular_dependencies, &r.config.root),
877 )
878 },
879 );
880 AuditKeySnapshot {
881 dead_code: check.map_or_else(FxHashSet::default, |r| {
882 dead_code_keys(&r.results, &r.config.root)
883 }),
884 health: health.map_or_else(FxHashSet::default, |r| {
885 health_keys(&r.report, &r.config.root)
886 }),
887 dupes: dupes.map_or_else(FxHashSet::default, |r| {
888 dupes_keys(&r.report, &r.config.root)
889 }),
890 boundary_edges,
891 cycles,
892 public_api,
893 }
894}
895
896fn public_api_keys_from_check(check: Option<&CheckResult>, root: &Path) -> FxHashSet<String> {
901 let Some(check) = check else {
902 return FxHashSet::default();
903 };
904 let Some(graph) = check
905 .shared_parse
906 .as_ref()
907 .and_then(|sp| sp.analysis_output.as_ref())
908 .and_then(|out| out.graph.as_ref())
909 else {
910 return FxHashSet::default();
911 };
912 let root_pkg = fallow_config::PackageJson::load(&check.config.root.join("package.json")).ok();
913 let workspaces = fallow_config::discover_workspaces(&check.config.root);
914 review_deltas::public_export_keys_for(
915 graph,
916 &check.config,
917 root_pkg.as_ref(),
918 &workspaces,
919 root,
920 )
921}
922
923#[expect(
925 clippy::ref_option,
926 reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
927)]
928fn build_base_audit_options<'a>(
929 opts: &AuditOptions<'a>,
930 base_root: &'a Path,
931 current_config_path: &'a Option<PathBuf>,
932 base_cache_dir: &'a Path,
933) -> AuditOptions<'a> {
934 AuditOptions {
935 root: base_root,
936 config_path: current_config_path,
937 cache_dir: base_cache_dir,
938 output: opts.output,
939 no_cache: opts.no_cache,
940 threads: opts.threads,
941 quiet: true,
942 changed_since: None,
943 production: opts.production,
944 production_dead_code: opts.production_dead_code,
945 production_health: opts.production_health,
946 production_dupes: opts.production_dupes,
947 workspace: opts.workspace,
948 changed_workspaces: None,
949 explain: false,
950 explain_skipped: false,
951 performance: false,
952 group_by: opts.group_by,
953 dead_code_baseline: None,
954 health_baseline: None,
955 dupes_baseline: None,
956 max_crap: opts.max_crap,
957 coverage: opts.coverage,
958 coverage_root: opts.coverage_root,
959 gate: AuditGate::All,
960 include_entry_exports: opts.include_entry_exports,
961 runtime_coverage: None,
962 min_invocations_hot: opts.min_invocations_hot,
963 brief: false,
964 max_decisions: 4,
965 walkthrough_guide: false,
966 walkthrough_file: None,
967 show_deprioritized: false,
968 }
969}
970
971fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
972 let Some(git_root) = git_toplevel(current_root) else {
973 return base_worktree_root.to_path_buf();
974 };
975 let current_root =
976 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
977 match current_root.strip_prefix(&git_root) {
978 Ok(relative) => base_worktree_root.join(relative),
979 Err(err) => {
980 tracing::warn!(
981 current_root = %current_root.display(),
982 git_root = %git_root.display(),
983 error = %err,
984 "Could not remap audit base root into the base worktree; falling back to worktree root"
985 );
986 base_worktree_root.to_path_buf()
987 }
988 }
989}
990
991fn current_keys_as_base_keys(
992 check: Option<&CheckResult>,
993 dupes: Option<&DupesResult>,
994 health: Option<&HealthResult>,
995) -> AuditKeySnapshot {
996 let public_api = check
1001 .and_then(|r| r.public_api_keys.clone())
1002 .unwrap_or_default();
1003 snapshot_from_results(check, dupes, health, public_api)
1004}
1005
1006fn can_reuse_current_as_base(
1007 opts: &AuditOptions<'_>,
1008 base_ref: &str,
1009 changed_files: &FxHashSet<PathBuf>,
1010) -> bool {
1011 let Some(git_root) = git_toplevel(opts.root) else {
1012 return false;
1013 };
1014 let cache_dir = opts.cache_dir.to_path_buf();
1015 let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
1016 let mut reader: Option<BaseFileReader> = None;
1019 for path in changed_files {
1020 if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
1021 continue;
1022 }
1023 if !is_analysis_input(path) {
1024 if is_non_behavioral_doc(path) {
1025 continue;
1026 }
1027 return false;
1028 }
1029 let Ok(current) = std::fs::read_to_string(path) else {
1030 return false;
1031 };
1032 let Ok(relative) = path.strip_prefix(&git_root) else {
1033 return false;
1034 };
1035 let reader = match reader.as_mut() {
1036 Some(reader) => reader,
1037 None => {
1038 let Some(spawned) = BaseFileReader::spawn(opts.root) else {
1039 return false;
1040 };
1041 reader.insert(spawned)
1042 }
1043 };
1044 let Some(base) = reader.read(base_ref, relative) else {
1045 return false;
1046 };
1047 if current == base {
1048 continue;
1049 }
1050 if !js_ts_tokens_equivalent(path, ¤t, &base) {
1051 return false;
1052 }
1053 }
1054 true
1055}
1056
1057struct BaseFileReader {
1070 child: Option<crate::signal::ScopedChild>,
1074 stdin: Option<std::process::ChildStdin>,
1077 stdout: std::io::BufReader<std::process::ChildStdout>,
1078}
1079
1080impl BaseFileReader {
1081 fn spawn(root: &Path) -> Option<Self> {
1087 let mut command = Command::new("git");
1088 command
1089 .args(["cat-file", "--batch"])
1090 .current_dir(root)
1091 .stdin(std::process::Stdio::piped())
1092 .stdout(std::process::Stdio::piped())
1093 .stderr(std::process::Stdio::null());
1094 clear_ambient_git_env(&mut command);
1095 let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
1096 let stdin = child.take_stdin()?;
1097 let stdout = child.take_stdout()?;
1098 Some(Self {
1099 child: Some(child),
1100 stdin: Some(stdin),
1101 stdout: std::io::BufReader::new(stdout),
1102 })
1103 }
1104
1105 fn read(&mut self, base_ref: &str, relative: &Path) -> Option<String> {
1112 use std::io::{BufRead, Read};
1113
1114 let relative = relative.to_string_lossy().replace('\\', "/");
1115 if relative.contains('\n') {
1118 return None;
1119 }
1120
1121 let stdin = self.stdin.as_mut()?;
1122 writeln!(stdin, "{base_ref}:{relative}").ok()?;
1123 stdin.flush().ok()?;
1124
1125 let mut header = String::new();
1126 if self.stdout.read_line(&mut header).ok()? == 0 {
1127 return None;
1128 }
1129 if header.trim_end().ends_with(" missing") {
1131 return None;
1132 }
1133 let size: usize = header.trim_end().rsplit(' ').next()?.parse().ok()?;
1135 let mut buf = vec![0u8; size];
1136 self.stdout.read_exact(&mut buf).ok()?;
1137 let mut newline = [0u8; 1];
1140 self.stdout.read_exact(&mut newline).ok()?;
1141
1142 Some(String::from_utf8_lossy(&buf).into_owned())
1143 }
1144}
1145
1146impl Drop for BaseFileReader {
1147 fn drop(&mut self) {
1148 self.stdin.take();
1153 if let Some(child) = self.child.take() {
1154 let _ = child.wait();
1155 }
1156 }
1157}
1158
1159fn is_fallow_cache_artifact(
1160 path: &Path,
1161 cache_dir: &Path,
1162 canonical_cache_dir: Option<&Path>,
1163) -> bool {
1164 path.starts_with(cache_dir)
1165 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
1166}
1167
1168fn remap_cache_dir_for_base_worktree(
1169 current_root: &Path,
1170 base_worktree_root: &Path,
1171 cache_dir: &Path,
1172) -> PathBuf {
1173 if cache_dir.is_absolute()
1174 && let Ok(relative) = cache_dir.strip_prefix(current_root)
1175 {
1176 return base_worktree_root.join(relative);
1177 }
1178 cache_dir.to_path_buf()
1179}
1180
1181fn is_analysis_input(path: &Path) -> bool {
1182 matches!(
1183 path.extension().and_then(|ext| ext.to_str()),
1184 Some(
1185 "js" | "jsx"
1186 | "ts"
1187 | "tsx"
1188 | "mjs"
1189 | "mts"
1190 | "cjs"
1191 | "cts"
1192 | "vue"
1193 | "svelte"
1194 | "astro"
1195 | "mdx"
1196 | "css"
1197 | "scss"
1198 )
1199 )
1200}
1201
1202fn is_non_behavioral_doc(path: &Path) -> bool {
1203 matches!(
1204 path.extension().and_then(|ext| ext.to_str()),
1205 Some("md" | "markdown" | "txt" | "rst" | "adoc")
1206 )
1207}
1208
1209fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
1210 if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
1211 return false;
1212 }
1213 if !matches!(
1214 path.extension().and_then(|ext| ext.to_str()),
1215 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
1216 ) {
1217 return false;
1218 }
1219 let current_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, current, false);
1220 let base_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, base, false);
1221 current_tokens
1222 .tokens
1223 .iter()
1224 .map(|token| &token.kind)
1225 .eq(base_tokens.tokens.iter().map(|token| &token.kind))
1226}
1227
1228fn remap_focus_files(
1229 files: &FxHashSet<PathBuf>,
1230 from_root: &Path,
1231 to_root: &Path,
1232) -> Option<FxHashSet<PathBuf>> {
1233 let mut remapped = FxHashSet::default();
1234 for file in files {
1235 if let Ok(relative) = file.strip_prefix(from_root) {
1236 remapped.insert(to_root.join(relative));
1237 }
1238 }
1239 if remapped.is_empty() {
1240 return None;
1241 }
1242 Some(remapped)
1243}
1244
1245#[cfg(test)]
1246use std::time::SystemTime;
1247
1248#[cfg(test)]
1249use crate::base_worktree::{
1250 ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
1251 is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
1252 materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
1253 remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
1254 sweep_orphan_audit_worktrees, touch_last_used,
1255};
1256
1257#[path = "audit_keys.rs"]
1258pub mod keys;
1259
1260#[path = "audit_review_deltas.rs"]
1261pub mod review_deltas;
1262
1263#[path = "audit_weakening.rs"]
1264pub mod weakening;
1265
1266#[path = "audit_routing.rs"]
1267pub mod routing;
1268
1269use keys::{
1270 dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
1271 retain_introduced_dead_code,
1272};
1273
1274struct HeadAnalyses {
1275 check: Option<CheckResult>,
1276 dupes: Option<DupesResult>,
1277 health: Option<HealthResult>,
1278}
1279
1280type HeadAndBaseResult = (
1283 Result<HeadAnalyses, ExitCode>,
1284 Option<Result<AuditKeySnapshot, ExitCode>>,
1285);
1286
1287fn run_audit_head_and_base(
1290 opts: &AuditOptions<'_>,
1291 changed_since: Option<&str>,
1292 changed_files: &FxHashSet<PathBuf>,
1293 base_ref: &str,
1294 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
1295 run_base: bool,
1296) -> HeadAndBaseResult {
1297 if run_base {
1298 let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
1299 let (h, b) = rayon::join(
1300 || run_audit_head_analyses(opts, changed_since, changed_files),
1301 || compute_base_snapshot(opts, base_ref, changed_files, base_sha),
1302 );
1303 (h, Some(b))
1304 } else {
1305 (
1306 run_audit_head_analyses(opts, changed_since, changed_files),
1307 None,
1308 )
1309 }
1310}
1311
1312struct AuditResultParts {
1313 verdict: AuditVerdict,
1314 summary: AuditSummary,
1315 attribution: AuditAttribution,
1316 base_snapshot: Option<AuditKeySnapshot>,
1317 base_snapshot_skipped: bool,
1318 changed_files_count: usize,
1319 changed_files: FxHashSet<PathBuf>,
1320 base_ref: String,
1321 base_description: Option<String>,
1322 head_sha: Option<String>,
1323 output: OutputFormat,
1324 performance: bool,
1325 check: Option<CheckResult>,
1326 dupes: Option<DupesResult>,
1327 health: Option<HealthResult>,
1328 elapsed: Duration,
1329 review_deltas: Option<crate::audit_brief::ReviewDeltas>,
1330 weakening_signals: Vec<weakening::WeakeningSignal>,
1331 routing: Option<routing::RoutingFacts>,
1332 decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
1333 graph_snapshot_hash: Option<String>,
1334 change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
1335}
1336
1337fn run_audit_head_analyses(
1344 opts: &AuditOptions<'_>,
1345 changed_since: Option<&str>,
1346 changed_files: &FxHashSet<PathBuf>,
1347) -> Result<HeadAnalyses, ExitCode> {
1348 let check_production = opts.production_dead_code.unwrap_or(opts.production);
1349 let health_production = opts.production_health.unwrap_or(opts.production);
1350 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
1351 let share_dead_code_parse_with_health = check_production == health_production;
1352 let share_dead_code_files_with_dupes =
1353 share_dead_code_parse_with_health && check_production == dupes_production;
1354
1355 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
1356 let dupes_files = if share_dead_code_files_with_dupes {
1357 check
1358 .as_ref()
1359 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
1360 } else {
1361 None
1362 };
1363 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
1364 if opts.brief
1369 && let Some(ref mut check) = check
1370 {
1371 check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
1372 check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
1373 check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
1374 check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
1375 check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
1376 check.internal_consumers =
1377 compute_brief_internal_consumers(opts.root, check, changed_files);
1378 }
1379 let shared_parse = if share_dead_code_parse_with_health {
1380 check.as_mut().and_then(|r| r.shared_parse.take())
1381 } else {
1382 None
1383 };
1384 let health = run_audit_health(opts, changed_since, shared_parse)?;
1385 Ok(HeadAnalyses {
1386 check,
1387 dupes,
1388 health,
1389 })
1390}
1391
1392fn compute_brief_impact_closure(
1401 root: &std::path::Path,
1402 check: &CheckResult,
1403 changed_files: &FxHashSet<PathBuf>,
1404) -> Option<fallow_core::graph::ImpactClosurePaths> {
1405 let graph = check
1406 .shared_parse
1407 .as_ref()
1408 .and_then(|sp| sp.analysis_output.as_ref())
1409 .and_then(|out| out.graph.as_ref())?;
1410
1411 let path_to_id: FxHashMap<String, fallow_types::discover::FileId> = graph
1415 .modules
1416 .iter()
1417 .map(|m| (m.path.to_string_lossy().replace('\\', "/"), m.file_id))
1418 .collect();
1419
1420 let changed_ids: Vec<fallow_types::discover::FileId> = changed_files
1421 .iter()
1422 .filter_map(|p| {
1423 let key = p.to_string_lossy().replace('\\', "/");
1424 path_to_id.get(&key).copied()
1425 })
1426 .collect();
1427
1428 if changed_ids.is_empty() {
1429 return None;
1430 }
1431
1432 let closure = graph.impact_closure(&changed_ids);
1433 Some(graph.closure_with_paths(&closure, root))
1434}
1435
1436fn compute_brief_partition_order(
1444 root: &std::path::Path,
1445 check: &CheckResult,
1446 changed_files: &FxHashSet<PathBuf>,
1447) -> Option<fallow_core::graph::PartitionOrderPaths> {
1448 let graph = check
1449 .shared_parse
1450 .as_ref()
1451 .and_then(|sp| sp.analysis_output.as_ref())
1452 .and_then(|out| out.graph.as_ref())?;
1453
1454 let path_to_id: FxHashMap<String, fallow_types::discover::FileId> = graph
1455 .modules
1456 .iter()
1457 .map(|m| (m.path.to_string_lossy().replace('\\', "/"), m.file_id))
1458 .collect();
1459
1460 let changed_ids: Vec<fallow_types::discover::FileId> = changed_files
1461 .iter()
1462 .filter_map(|p| {
1463 let key = p.to_string_lossy().replace('\\', "/");
1464 path_to_id.get(&key).copied()
1465 })
1466 .collect();
1467
1468 if changed_ids.is_empty() {
1469 return None;
1470 }
1471
1472 let partition = graph.partition_order(&changed_ids);
1473 Some(graph.partition_order_with_paths(&partition, root))
1474}
1475
1476fn compute_brief_export_lines(
1481 root: &std::path::Path,
1482 check: &CheckResult,
1483 changed_files: &FxHashSet<PathBuf>,
1484) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
1485 let graph = check
1486 .shared_parse
1487 .as_ref()
1488 .and_then(|sp| sp.analysis_output.as_ref())
1489 .and_then(|out| out.graph.as_ref())?;
1490
1491 let changed_norm: FxHashSet<String> = changed_files
1492 .iter()
1493 .map(|p| p.to_string_lossy().replace('\\', "/"))
1494 .collect();
1495
1496 let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
1497 for module in &graph.modules {
1498 let abs = module.path.to_string_lossy().replace('\\', "/");
1499 if !changed_norm.contains(&abs) || module.exports.is_empty() {
1500 continue;
1501 }
1502 let Ok(content) = std::fs::read_to_string(&module.path) else {
1503 continue;
1504 };
1505 let offsets = fallow_types::extract::compute_line_offsets(&content);
1506 let exports: Vec<(String, u32)> = module
1507 .exports
1508 .iter()
1509 .map(|export| {
1510 let (line, _) =
1511 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
1512 (export.name.to_string(), line)
1513 })
1514 .collect();
1515 map.insert(keys::relative_key_path(&module.path, root), exports);
1516 }
1517 Some(map)
1518}
1519
1520fn compute_brief_internal_consumers(
1529 root: &std::path::Path,
1530 check: &CheckResult,
1531 changed_files: &FxHashSet<PathBuf>,
1532) -> Option<FxHashMap<String, u64>> {
1533 let graph = check
1534 .shared_parse
1535 .as_ref()
1536 .and_then(|sp| sp.analysis_output.as_ref())
1537 .and_then(|out| out.graph.as_ref())?;
1538
1539 let changed_norm: FxHashSet<String> = changed_files
1540 .iter()
1541 .map(|p| p.to_string_lossy().replace('\\', "/"))
1542 .collect();
1543 let id_to_norm: FxHashMap<fallow_types::discover::FileId, String> = graph
1544 .modules
1545 .iter()
1546 .map(|m| (m.file_id, m.path.to_string_lossy().replace('\\', "/")))
1547 .collect();
1548
1549 let mut map: FxHashMap<String, u64> = FxHashMap::default();
1550 for module in &graph.modules {
1551 let abs = module.path.to_string_lossy().replace('\\', "/");
1552 if !changed_norm.contains(&abs) {
1553 continue;
1554 }
1555 let count = graph
1556 .importers_of(module.file_id)
1557 .iter()
1558 .filter(|imp| {
1559 id_to_norm
1560 .get(imp)
1561 .is_none_or(|p| !changed_norm.contains(p))
1562 })
1563 .count() as u64;
1564 map.insert(keys::relative_key_path(&module.path, root), count);
1565 }
1566 Some(map)
1567}
1568
1569fn compute_brief_focus_facts(
1579 root: &std::path::Path,
1580 check: &CheckResult,
1581 changed_files: &FxHashSet<PathBuf>,
1582) -> Option<Vec<fallow_core::graph::FocusFileFactsPaths>> {
1583 let graph = check
1584 .shared_parse
1585 .as_ref()
1586 .and_then(|sp| sp.analysis_output.as_ref())
1587 .and_then(|out| out.graph.as_ref())?;
1588
1589 let path_to_id: FxHashMap<String, fallow_types::discover::FileId> = graph
1590 .modules
1591 .iter()
1592 .map(|m| (m.path.to_string_lossy().replace('\\', "/"), m.file_id))
1593 .collect();
1594
1595 let changed_ids: Vec<fallow_types::discover::FileId> = changed_files
1596 .iter()
1597 .filter_map(|p| {
1598 let key = p.to_string_lossy().replace('\\', "/");
1599 path_to_id.get(&key).copied()
1600 })
1601 .collect();
1602
1603 if changed_ids.is_empty() {
1604 return None;
1605 }
1606
1607 let facts = graph.focus_file_facts(&changed_ids);
1608 Some(graph.focus_facts_with_paths(&facts, root))
1609}
1610
1611pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1613 let start = Instant::now();
1614
1615 let (base_ref, base_description) = resolve_base_ref(opts)?;
1616
1617 sweep_old_reusable_caches(
1621 opts.root,
1622 resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1623 opts.quiet,
1624 );
1625
1626 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1627 return Err(emit_error(
1628 &format!(
1629 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1630 ),
1631 2,
1632 opts.output,
1633 ));
1634 };
1635 let changed_files_count = changed_files.len();
1636
1637 if changed_files.is_empty() {
1638 return Ok(empty_audit_result(
1639 base_ref,
1640 base_description,
1641 opts,
1642 start.elapsed(),
1643 ));
1644 }
1645
1646 let changed_since = Some(base_ref.as_str());
1647
1648 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1649 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1650 let base_cache_key = if needs_real_base_snapshot {
1651 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1652 } else {
1653 None
1654 };
1655 let cached_base_snapshot = base_cache_key
1656 .as_ref()
1657 .and_then(|key| load_cached_base_snapshot(opts, key));
1658
1659 let (head_res, base_res) = run_audit_head_and_base(
1660 opts,
1661 changed_since,
1662 &changed_files,
1663 &base_ref,
1664 base_cache_key.as_ref(),
1665 needs_real_base_snapshot && cached_base_snapshot.is_none(),
1666 );
1667
1668 assemble_audit_result(AuditAssemblyInput {
1669 opts,
1670 head_res,
1671 base_res,
1672 cached_base_snapshot,
1673 base_cache_key,
1674 changed_files,
1675 changed_files_count,
1676 base_ref,
1677 base_description,
1678 start,
1679 })
1680}
1681
1682struct AuditAssemblyInput<'a> {
1684 opts: &'a AuditOptions<'a>,
1685 head_res: Result<HeadAnalyses, ExitCode>,
1686 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1687 cached_base_snapshot: Option<AuditKeySnapshot>,
1688 base_cache_key: Option<AuditBaseSnapshotCacheKey>,
1689 changed_files: FxHashSet<PathBuf>,
1690 changed_files_count: usize,
1691 base_ref: String,
1692 base_description: Option<String>,
1693 start: Instant,
1694}
1695
1696fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
1699 let opts = input.opts;
1700 let head = input.head_res?;
1701 let mut check_result = head.check;
1702 let dupes_result = head.dupes;
1703 let health_result = head.health;
1704
1705 let (base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
1706 opts,
1707 input.cached_base_snapshot,
1708 input.base_res,
1709 input.base_cache_key.as_ref(),
1710 CurrentAnalysisRefs {
1711 check: check_result.as_ref(),
1712 dupes: dupes_result.as_ref(),
1713 health: health_result.as_ref(),
1714 },
1715 )?;
1716 if let Some(ref mut check) = check_result {
1717 check.shared_parse = None;
1718 }
1719 let (attribution, verdict, summary) = compute_audit_outcome(
1720 opts.gate,
1721 check_result.as_ref(),
1722 dupes_result.as_ref(),
1723 health_result.as_ref(),
1724 base_snapshot.as_ref(),
1725 );
1726
1727 let (review_deltas, weakening_signals, routing) = if opts.brief {
1730 compute_brief_e3_data(
1731 opts,
1732 check_result.as_ref(),
1733 base_snapshot.as_ref(),
1734 &input.changed_files,
1735 &input.base_ref,
1736 )
1737 } else {
1738 (None, Vec::new(), None)
1739 };
1740
1741 let decision_surface = if opts.brief {
1745 Some(compute_decision_surface(
1746 opts,
1747 check_result.as_ref(),
1748 review_deltas.as_ref(),
1749 routing.as_ref(),
1750 ))
1751 } else {
1752 None
1753 };
1754
1755 let change_anchors = if opts.brief {
1760 compute_change_anchors(opts.root, &input.base_ref)
1761 } else {
1762 Vec::new()
1763 };
1764
1765 let head_sha = get_head_sha(opts.root);
1771 let graph_snapshot_hash = if opts.brief {
1772 Some(compute_graph_snapshot_hash(
1773 check_result.as_ref(),
1774 dupes_result.as_ref(),
1775 health_result.as_ref(),
1776 &input.base_ref,
1777 head_sha.as_deref(),
1778 &change_anchors,
1779 ))
1780 } else {
1781 None
1782 };
1783
1784 Ok(build_audit_result(AuditResultParts {
1785 verdict,
1786 summary,
1787 attribution,
1788 base_snapshot,
1789 base_snapshot_skipped,
1790 changed_files_count: input.changed_files_count,
1791 changed_files: input.changed_files,
1792 base_ref: input.base_ref,
1793 base_description: input.base_description,
1794 head_sha,
1795 output: opts.output,
1796 performance: opts.performance,
1797 check: check_result,
1798 dupes: dupes_result,
1799 health: health_result,
1800 elapsed: input.start.elapsed(),
1801 review_deltas,
1802 weakening_signals,
1803 routing,
1804 decision_surface,
1805 graph_snapshot_hash,
1806 change_anchors,
1807 }))
1808}
1809
1810fn compute_graph_snapshot_hash(
1820 check: Option<&CheckResult>,
1821 dupes: Option<&DupesResult>,
1822 health: Option<&HealthResult>,
1823 base_ref: &str,
1824 head_sha: Option<&str>,
1825 change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
1826) -> String {
1827 let public_api = check
1831 .and_then(|c| c.public_api_keys.clone())
1832 .unwrap_or_default();
1833 let snapshot = snapshot_from_results(check, dupes, health, public_api);
1834 let mut bytes: Vec<u8> = Vec::new();
1835 for set in [
1837 &snapshot.dead_code,
1838 &snapshot.health,
1839 &snapshot.dupes,
1840 &snapshot.boundary_edges,
1841 &snapshot.cycles,
1842 &snapshot.public_api,
1843 ] {
1844 for key in sorted_keys(set) {
1845 bytes.extend_from_slice(key.as_bytes());
1846 bytes.push(0);
1847 }
1848 bytes.push(1);
1849 }
1850 let mut anchor_ids: Vec<&str> = change_anchors
1855 .iter()
1856 .map(|a| a.change_anchor.as_str())
1857 .collect();
1858 anchor_ids.sort_unstable();
1859 for id in anchor_ids {
1860 bytes.extend_from_slice(id.as_bytes());
1861 bytes.push(0);
1862 }
1863 bytes.push(1);
1864 bytes.extend_from_slice(base_ref.as_bytes());
1865 bytes.push(0);
1866 bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
1867 format!("graph:{:016x}", xxh3_64(&bytes))
1868}
1869
1870fn compute_change_anchors(
1878 root: &std::path::Path,
1879 base_ref: &str,
1880) -> Vec<crate::audit_walkthrough::ChangeAnchor> {
1881 if let Some(raw) = crate::report::ci::diff_filter::shared_diff_raw() {
1882 return crate::audit_walkthrough::parse_change_anchors(raw);
1883 }
1884 fallow_core::changed_files::try_get_changed_diff(root, base_ref)
1885 .map(|diff| crate::audit_walkthrough::parse_change_anchors(&diff))
1886 .unwrap_or_default()
1887}
1888
1889fn compute_decision_surface(
1895 opts: &AuditOptions<'_>,
1896 check: Option<&CheckResult>,
1897 review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
1898 routing: Option<&routing::RoutingFacts>,
1899) -> crate::audit_decision_surface::DecisionSurface {
1900 use crate::audit_decision_surface::{
1901 BoundaryAnchor, CoordinationAnchor, DecisionInputs, extract_decision_surface,
1902 };
1903
1904 let (Some(check), Some(deltas)) = (check, review_deltas) else {
1905 return crate::audit_decision_surface::DecisionSurface::default();
1906 };
1907 let root = &check.config.root;
1908
1909 let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
1913 let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
1914 for finding in &check.results.boundary_violations {
1915 let key = review_deltas::boundary_edge_key(finding);
1916 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
1917 continue;
1918 }
1919 boundary_anchors.push(BoundaryAnchor {
1920 zone_pair_key: key,
1921 from_file: keys::relative_key_path(&finding.violation.from_path, root),
1922 from_zone: finding.violation.from_zone.clone(),
1923 to_zone: finding.violation.to_zone.clone(),
1924 line: finding.violation.line,
1925 });
1926 }
1927
1928 let closure = check.impact_closure.as_ref();
1932 let mut coordination: Vec<CoordinationAnchor> = closure
1933 .map(|c| aggregate_coordination_gaps(&c.coordination_gap))
1934 .unwrap_or_default();
1935 let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
1936
1937 let empty_routing = routing::RoutingFacts::default();
1938 let routing = routing.unwrap_or(&empty_routing);
1939
1940 let root_owned = root.clone();
1944 let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
1945
1946 let export_lines = check.export_lines.as_ref();
1951 let resolve_line = |rel: &str, symbols: &[String]| -> u32 {
1952 let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
1953 return 0;
1954 };
1955 exports
1956 .iter()
1957 .find(|(name, _)| symbols.iter().any(|s| name == s))
1958 .or_else(|| exports.first())
1959 .map_or(0, |(_, line)| *line)
1960 };
1961 for anchor in &mut coordination {
1962 anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
1963 }
1964 let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
1965 let mut parts = key.splitn(2, "::");
1966 let path = parts.next().unwrap_or_default();
1967 let name = parts.next().unwrap_or_default();
1968 resolve_line(path, &[name.to_string()])
1969 });
1970
1971 let rename_old_path = |rel: &str| -> Option<String> {
1975 crate::report::ci::diff_filter::shared_diff_index()
1976 .and_then(|idx| idx.old_path_for(rel))
1977 .map(str::to_string)
1978 };
1979
1980 let internal_consumers_map = check.internal_consumers.as_ref();
1983 let internal_consumers = |rel: &str| -> u64 {
1984 internal_consumers_map
1985 .and_then(|map| map.get(rel))
1986 .copied()
1987 .unwrap_or(0)
1988 };
1989
1990 extract_decision_surface(&DecisionInputs {
1991 deltas,
1992 boundary_anchors: &boundary_anchors,
1993 coordination: &coordination,
1994 public_api_anchor_line,
1995 affected_not_shown,
1996 routing,
1997 head_source: &head_source,
1998 rename_old_path: &rename_old_path,
1999 internal_consumers: &internal_consumers,
2000 cap: opts.max_decisions,
2001 })
2002}
2003
2004fn aggregate_coordination_gaps(
2009 gaps: &[fallow_core::graph::CoordinationGapPaths],
2010) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
2011 use crate::audit_decision_surface::CoordinationAnchor;
2012 let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
2013 for gap in gaps {
2014 let entry = by_file
2015 .entry(gap.changed_file.clone())
2016 .or_insert_with(|| (0, FxHashSet::default()));
2017 entry.0 += 1;
2018 for symbol in &gap.consumed_symbols {
2019 entry.1.insert(symbol.clone());
2020 }
2021 }
2022 let mut anchors: Vec<CoordinationAnchor> = by_file
2023 .into_iter()
2024 .map(|(changed_file, (consumer_count, symbols))| {
2025 let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
2026 consumed_symbols.sort_unstable();
2027 CoordinationAnchor {
2028 changed_file,
2029 consumed_symbols,
2030 consumer_count,
2031 line: 0,
2032 }
2033 })
2034 .collect();
2035 anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
2036 anchors
2037}
2038
2039fn compute_brief_e3_data(
2044 opts: &AuditOptions<'_>,
2045 check: Option<&CheckResult>,
2046 base_snapshot: Option<&AuditKeySnapshot>,
2047 changed_files: &FxHashSet<PathBuf>,
2048 base_ref: &str,
2049) -> (
2050 Option<crate::audit_brief::ReviewDeltas>,
2051 Vec<weakening::WeakeningSignal>,
2052 Option<routing::RoutingFacts>,
2053) {
2054 let deltas = check.map(|check| {
2055 let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
2056 let head_cycles =
2057 review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
2058 let head_public_api = check.public_api_keys.clone().unwrap_or_default();
2059 let empty = FxHashSet::default();
2060 let (base_boundary, base_cycles, base_public_api) = base_snapshot
2061 .map_or((&empty, &empty, &empty), |b| {
2062 (&b.boundary_edges, &b.cycles, &b.public_api)
2063 });
2064 crate::audit_brief::build_review_deltas(
2065 &head_boundary,
2066 base_boundary,
2067 &head_cycles,
2068 base_cycles,
2069 &head_public_api,
2070 base_public_api,
2071 )
2072 });
2073
2074 let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
2075
2076 let routing =
2077 check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
2078
2079 (deltas, weakening_signals, routing)
2080}
2081
2082fn compute_weakening_signals(
2087 root: &Path,
2088 base_ref: &str,
2089 changed_files: &FxHashSet<PathBuf>,
2090) -> Vec<weakening::WeakeningSignal> {
2091 use weakening::{WeakeningKind, WeakeningSignal};
2092
2093 let Some(git_root) = git_toplevel(root) else {
2094 return Vec::new();
2095 };
2096 let Some(mut reader) = BaseFileReader::spawn(root) else {
2097 return Vec::new();
2098 };
2099
2100 let mut signals: Vec<WeakeningSignal> = Vec::new();
2101 let mut files: Vec<&PathBuf> = changed_files.iter().collect();
2103 files.sort();
2104
2105 for abs in files {
2106 let Ok(relative) = abs.strip_prefix(&git_root) else {
2107 continue;
2108 };
2109 let rel_str = relative.to_string_lossy().replace('\\', "/");
2110 let head = std::fs::read_to_string(abs).unwrap_or_default();
2111 let base = reader.read(base_ref, relative).unwrap_or_default();
2112 if weakening::is_test_file(&rel_str) {
2116 for token in weakening::detect_test_weakening(&base, &head) {
2117 signals.push(WeakeningSignal {
2118 kind: WeakeningKind::TestWeakened,
2119 file: rel_str.clone(),
2120 evidence: format!("{token} added"),
2121 });
2122 }
2123 for ev in weakening::detect_removed_tests(&base, &head) {
2124 signals.push(WeakeningSignal {
2125 kind: WeakeningKind::TestWeakened,
2126 file: rel_str.clone(),
2127 evidence: ev,
2128 });
2129 }
2130 }
2131 for ev in weakening::detect_added_suppressions(&base, &head) {
2132 signals.push(WeakeningSignal {
2133 kind: WeakeningKind::SuppressionAdded,
2134 file: rel_str.clone(),
2135 evidence: ev,
2136 });
2137 }
2138 for ev in weakening::detect_lowered_thresholds(&base, &head) {
2139 signals.push(WeakeningSignal {
2140 kind: WeakeningKind::ThresholdLowered,
2141 file: rel_str.clone(),
2142 evidence: ev,
2143 });
2144 }
2145 if weakening::is_ci_file(&rel_str) {
2146 for ev in weakening::detect_removed_security_steps(&base, &head) {
2147 signals.push(WeakeningSignal {
2148 kind: WeakeningKind::SecurityCheckRemoved,
2149 file: rel_str.clone(),
2150 evidence: ev,
2151 });
2152 }
2153 }
2154 }
2155 signals
2156}
2157
2158fn compute_audit_outcome(
2161 gate: AuditGate,
2162 check: Option<&CheckResult>,
2163 dupes: Option<&DupesResult>,
2164 health: Option<&HealthResult>,
2165 base: Option<&AuditKeySnapshot>,
2166) -> (AuditAttribution, AuditVerdict, AuditSummary) {
2167 let attribution = compute_audit_attribution(check, dupes, health, base, gate);
2168 let verdict = compute_audit_verdict(gate, check, dupes, health, base);
2169 let summary = build_summary(check, dupes, health);
2170 crate::telemetry::note_final_result_count(
2171 summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
2172 );
2173 (attribution, verdict, summary)
2174}
2175
2176#[derive(Clone, Copy)]
2183struct CurrentAnalysisRefs<'a> {
2184 check: Option<&'a CheckResult>,
2185 dupes: Option<&'a DupesResult>,
2186 health: Option<&'a HealthResult>,
2187}
2188
2189fn resolve_base_snapshot(
2190 opts: &AuditOptions<'_>,
2191 cached_base_snapshot: Option<AuditKeySnapshot>,
2192 base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
2193 base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
2194 current: CurrentAnalysisRefs<'_>,
2195) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
2196 if !matches!(opts.gate, AuditGate::NewOnly) {
2197 return Ok((None, false));
2198 }
2199 if let Some(snapshot) = cached_base_snapshot {
2200 return Ok((Some(snapshot), false));
2201 }
2202 if let Some(base_res) = base_res {
2203 let snapshot = base_res?;
2204 if let Some(key) = base_cache_key {
2205 save_cached_base_snapshot(opts, key, &snapshot);
2206 }
2207 return Ok((Some(snapshot), false));
2208 }
2209 let CurrentAnalysisRefs {
2210 check,
2211 dupes,
2212 health,
2213 } = current;
2214 Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
2215}
2216
2217fn compute_audit_verdict(
2220 gate: AuditGate,
2221 check: Option<&CheckResult>,
2222 dupes: Option<&DupesResult>,
2223 health: Option<&HealthResult>,
2224 base: Option<&AuditKeySnapshot>,
2225) -> AuditVerdict {
2226 if matches!(gate, AuditGate::NewOnly) {
2227 compute_introduced_verdict(check, dupes, health, base)
2228 } else {
2229 compute_verdict(check, dupes, health)
2230 }
2231}
2232
2233fn build_audit_result(parts: AuditResultParts) -> AuditResult {
2234 AuditResult {
2235 verdict: parts.verdict,
2236 summary: parts.summary,
2237 attribution: parts.attribution,
2238 base_snapshot: parts.base_snapshot,
2239 base_snapshot_skipped: parts.base_snapshot_skipped,
2240 changed_files_count: parts.changed_files_count,
2241 changed_files: parts.changed_files.into_iter().collect(),
2242 base_ref: parts.base_ref,
2243 base_description: parts.base_description,
2244 head_sha: parts.head_sha,
2245 output: parts.output,
2246 performance: parts.performance,
2247 check: parts.check,
2248 dupes: parts.dupes,
2249 health: parts.health,
2250 elapsed: parts.elapsed,
2251 review_deltas: parts.review_deltas,
2252 weakening_signals: parts.weakening_signals,
2253 routing: parts.routing,
2254 decision_surface: parts.decision_surface,
2255 graph_snapshot_hash: parts.graph_snapshot_hash,
2256 change_anchors: parts.change_anchors,
2257 }
2258}
2259
2260fn parse_audit_base_override(raw: Option<String>) -> Option<String> {
2263 let trimmed = raw?.trim().to_string();
2264 if trimmed.is_empty() {
2265 None
2266 } else {
2267 Some(trimmed)
2268 }
2269}
2270
2271fn audit_base_env_override() -> Option<String> {
2275 parse_audit_base_override(std::env::var("FALLOW_AUDIT_BASE").ok())
2276}
2277
2278fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<(String, Option<String>), ExitCode> {
2282 if let Some(ref_str) = opts.changed_since {
2283 return Ok((ref_str.to_string(), None));
2284 }
2285 if let Some(env_ref) = audit_base_env_override() {
2286 if let Err(e) = crate::validate::validate_git_ref(&env_ref) {
2287 return Err(emit_error(
2288 &format!("FALLOW_AUDIT_BASE='{env_ref}' is not a valid git ref: {e}"),
2289 2,
2290 opts.output,
2291 ));
2292 }
2293 let description = format!("FALLOW_AUDIT_BASE={env_ref}");
2294 return Ok((env_ref, Some(description)));
2295 }
2296 let Some(detected) = auto_detect_base_ref(opts.root) else {
2297 return Err(emit_error(
2298 "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
2299 2,
2300 opts.output,
2301 ));
2302 };
2303 if let Err(e) = crate::validate::validate_git_ref(&detected.git_ref) {
2304 return Err(emit_error(
2305 &format!(
2306 "auto-detected base ref '{}' is not a valid git ref: {e}",
2307 detected.git_ref
2308 ),
2309 2,
2310 opts.output,
2311 ));
2312 }
2313 Ok((detected.git_ref, Some(detected.description)))
2314}
2315
2316fn empty_audit_result(
2318 base_ref: String,
2319 base_description: Option<String>,
2320 opts: &AuditOptions<'_>,
2321 elapsed: Duration,
2322) -> AuditResult {
2323 crate::telemetry::note_final_result_count(0);
2324
2325 let head_sha = get_head_sha(opts.root);
2326 let graph_snapshot_hash = if opts.brief {
2330 Some(compute_graph_snapshot_hash(
2332 None,
2333 None,
2334 None,
2335 &base_ref,
2336 head_sha.as_deref(),
2337 &[],
2338 ))
2339 } else {
2340 None
2341 };
2342
2343 AuditResult {
2344 verdict: AuditVerdict::Pass,
2345 summary: AuditSummary {
2346 dead_code_issues: 0,
2347 dead_code_has_errors: false,
2348 complexity_findings: 0,
2349 max_cyclomatic: None,
2350 duplication_clone_groups: 0,
2351 },
2352 attribution: AuditAttribution {
2353 gate: opts.gate,
2354 ..AuditAttribution::default()
2355 },
2356 base_snapshot: None,
2357 base_snapshot_skipped: false,
2358 changed_files_count: 0,
2359 changed_files: Vec::new(),
2360 base_ref,
2361 base_description,
2362 head_sha,
2363 output: opts.output,
2364 performance: opts.performance,
2365 check: None,
2366 dupes: None,
2367 health: None,
2368 elapsed,
2369 review_deltas: None,
2370 weakening_signals: Vec::new(),
2371 routing: None,
2372 decision_surface: None,
2373 graph_snapshot_hash,
2374 change_anchors: Vec::new(),
2375 }
2376}
2377
2378fn run_audit_check<'a>(
2380 opts: &'a AuditOptions<'a>,
2381 changed_since: Option<&'a str>,
2382 retain_modules_for_health: bool,
2383) -> Result<Option<CheckResult>, ExitCode> {
2384 let filters = IssueFilters::default();
2385 let retain_modules_for_health = retain_modules_for_health || opts.brief;
2390 let trace_opts = TraceOptions {
2391 trace_export: None,
2392 trace_file: None,
2393 trace_dependency: None,
2394 impact_closure: None,
2395 performance: opts.performance,
2396 };
2397 match crate::check::execute_check(&CheckOptions {
2398 root: opts.root,
2399 config_path: opts.config_path,
2400 output: opts.output,
2401 no_cache: opts.no_cache,
2402 threads: opts.threads,
2403 quiet: opts.quiet,
2404 fail_on_issues: false,
2405 filters: &filters,
2406 changed_since,
2407 diff_index: None,
2408 use_shared_diff_index: true,
2409 baseline: opts.dead_code_baseline,
2410 save_baseline: None,
2411 sarif_file: None,
2412 production: opts.production_dead_code.unwrap_or(opts.production),
2413 production_override: opts.production_dead_code,
2414 workspace: opts.workspace,
2415 changed_workspaces: opts.changed_workspaces,
2416 group_by: opts.group_by,
2417 include_dupes: false,
2418 trace_opts: &trace_opts,
2419 explain: opts.explain,
2420 top: None,
2421 file: &[],
2422 include_entry_exports: opts.include_entry_exports,
2423 summary: false,
2424 regression_opts: crate::regression::RegressionOpts {
2425 fail_on_regression: false,
2426 tolerance: crate::regression::Tolerance::Absolute(0),
2427 regression_baseline_file: None,
2428 save_target: crate::regression::SaveRegressionTarget::None,
2429 scoped: true,
2430 quiet: opts.quiet,
2431 output: opts.output,
2432 },
2433 retain_modules_for_health,
2434 defer_performance: false,
2435 }) {
2436 Ok(r) => Ok(Some(r)),
2437 Err(code) => Err(code),
2438 }
2439}
2440
2441fn run_audit_dupes<'a>(
2447 opts: &'a AuditOptions<'a>,
2448 changed_since: Option<&'a str>,
2449 changed_files: Option<&'a FxHashSet<PathBuf>>,
2450 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2451) -> Result<Option<DupesResult>, ExitCode> {
2452 let dupes_cfg = match crate::load_config_for_analysis(
2453 opts.root,
2454 opts.config_path,
2455 crate::ConfigLoadOptions {
2456 output: opts.output,
2457 no_cache: opts.no_cache,
2458 threads: opts.threads,
2459 production_override: opts
2460 .production_dupes
2461 .or_else(|| opts.production.then_some(true)),
2462 quiet: opts.quiet,
2463 },
2464 fallow_config::ProductionAnalysis::Dupes,
2465 ) {
2466 Ok(c) => c.duplicates,
2467 Err(code) => return Err(code),
2468 };
2469 let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
2470 let dupes_run = if let Some(files) = pre_discovered {
2471 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2472 } else {
2473 crate::dupes::execute_dupes(&dupes_opts)
2474 };
2475 match dupes_run {
2476 Ok(r) => Ok(Some(r)),
2477 Err(code) => Err(code),
2478 }
2479}
2480
2481fn build_audit_dupes_options<'a>(
2483 opts: &'a AuditOptions<'a>,
2484 changed_since: Option<&'a str>,
2485 changed_files: Option<&'a FxHashSet<PathBuf>>,
2486 dupes_cfg: &fallow_config::DuplicatesConfig,
2487) -> DupesOptions<'a> {
2488 DupesOptions {
2489 root: opts.root,
2490 config_path: opts.config_path,
2491 output: opts.output,
2492 no_cache: opts.no_cache,
2493 threads: opts.threads,
2494 quiet: opts.quiet,
2495 mode: Some(DupesMode::from(dupes_cfg.mode)),
2496 min_tokens: Some(dupes_cfg.min_tokens),
2497 min_lines: Some(dupes_cfg.min_lines),
2498 min_occurrences: Some(dupes_cfg.min_occurrences),
2499 threshold: Some(dupes_cfg.threshold),
2500 skip_local: dupes_cfg.skip_local,
2501 cross_language: dupes_cfg.cross_language,
2502 ignore_imports: Some(dupes_cfg.ignore_imports),
2503 top: None,
2504 baseline_path: opts.dupes_baseline,
2505 save_baseline_path: None,
2506 production: opts.production_dupes.unwrap_or(opts.production),
2507 production_override: opts.production_dupes,
2508 trace: None,
2509 changed_since,
2510 diff_index: None,
2511 use_shared_diff_index: true,
2512 changed_files,
2513 workspace: opts.workspace,
2514 changed_workspaces: opts.changed_workspaces,
2515 explain: opts.explain,
2516 explain_skipped: opts.explain_skipped,
2517 summary: false,
2518 group_by: opts.group_by,
2519 performance: false,
2520 }
2521}
2522
2523fn run_audit_health<'a>(
2525 opts: &'a AuditOptions<'a>,
2526 changed_since: Option<&'a str>,
2527 shared_parse: Option<crate::health::SharedParseData>,
2528) -> Result<Option<HealthResult>, ExitCode> {
2529 let runtime_coverage = match opts.runtime_coverage {
2530 Some(path) => match crate::health::coverage::prepare_options(
2531 path,
2532 opts.min_invocations_hot,
2533 None,
2534 None,
2535 opts.output,
2536 ) {
2537 Ok(options) => Some(options),
2538 Err(code) => return Err(code),
2539 },
2540 None => None,
2541 };
2542
2543 let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
2544 let health_run = if let Some(shared) = shared_parse {
2545 crate::health::execute_health_with_shared_parse(&health_opts, shared)
2546 } else {
2547 crate::health::execute_health(&health_opts)
2548 };
2549 match health_run {
2550 Ok(r) => Ok(Some(r)),
2551 Err(code) => Err(code),
2552 }
2553}
2554
2555fn build_audit_health_options<'a>(
2558 opts: &'a AuditOptions<'a>,
2559 changed_since: Option<&'a str>,
2560 runtime_coverage: Option<crate::health::RuntimeCoverageOptions>,
2561) -> HealthOptions<'a> {
2562 HealthOptions {
2563 root: opts.root,
2564 config_path: opts.config_path,
2565 output: opts.output,
2566 no_cache: opts.no_cache,
2567 threads: opts.threads,
2568 quiet: opts.quiet,
2569 max_cyclomatic: None,
2570 max_cognitive: None,
2571 max_crap: opts.max_crap,
2572 top: None,
2573 sort: SortBy::Cyclomatic,
2574 production: opts.production_health.unwrap_or(opts.production),
2575 production_override: opts.production_health,
2576 changed_since,
2577 diff_index: None,
2578 use_shared_diff_index: true,
2579 workspace: opts.workspace,
2580 changed_workspaces: opts.changed_workspaces,
2581 baseline: opts.health_baseline,
2582 save_baseline: None,
2583 complexity: true,
2584 complexity_breakdown: false,
2585 file_scores: false,
2586 coverage_gaps: false,
2587 config_activates_coverage_gaps: false,
2588 hotspots: false,
2589 ownership: false,
2590 ownership_emails: None,
2591 targets: false,
2592 css: false,
2593 force_full: false,
2594 score_only_output: false,
2595 enforce_coverage_gap_gate: false,
2596 effort: None,
2597 score: false,
2598 min_score: None,
2599 since: None,
2600 min_commits: None,
2601 explain: opts.explain,
2602 summary: false,
2603 save_snapshot: None,
2604 trend: false,
2605 group_by: opts.group_by,
2606 coverage: opts.coverage,
2607 coverage_root: opts.coverage_root,
2608 performance: opts.performance,
2609 min_severity: None,
2610 report_only: false,
2611 runtime_coverage,
2612 churn_file: None,
2613 }
2614}
2615
2616#[path = "audit_output.rs"]
2617mod output;
2618
2619pub use output::{
2620 insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
2621 insert_audit_json_header, print_audit_findings, print_audit_result,
2622};
2623
2624pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
2630 if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
2631 return emit_error(&e, 2, opts.output);
2632 }
2633 let coverage_resolved = opts
2634 .coverage
2635 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2636 let runtime_coverage_resolved = opts
2637 .runtime_coverage
2638 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2639 let resolved_opts = AuditOptions {
2640 coverage: coverage_resolved.as_deref(),
2641 runtime_coverage: runtime_coverage_resolved.as_deref(),
2642 ..*opts
2643 };
2644 match execute_audit(&resolved_opts) {
2645 Ok(result) => {
2646 let mut findings = result
2647 .check
2648 .as_ref()
2649 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
2650 .unwrap_or_default();
2651 if let Some(health) = result.health.as_ref() {
2652 findings.extend(crate::impact::collect_complexity_findings(&health.report));
2653 }
2654 let clones = result
2655 .dupes
2656 .as_ref()
2657 .map(|d| crate::impact::collect_clone_findings(&d.report))
2658 .unwrap_or_default();
2659 let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
2660 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
2661 c.results.active_suppressions.as_slice()
2662 });
2663 let attribution = crate::impact::AttributionInput {
2664 root: opts.root,
2665 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
2666 findings,
2667 clones,
2668 suppressions,
2669 };
2670 crate::impact::record_audit_run(
2671 opts.root,
2672 &result.summary,
2673 &crate::impact::AuditRunRecord {
2674 verdict: result.verdict,
2675 gate: gate_marker.is_some(),
2676 git_sha: result.head_sha.as_deref(),
2677 version: env!("CARGO_PKG_VERSION"),
2678 timestamp: &crate::vital_signs::chrono_timestamp(),
2679 attribution: Some(&attribution),
2680 },
2681 );
2682 if opts.walkthrough_guide {
2685 crate::audit_brief::print_walkthrough_guide_result(&result)
2686 } else if let Some(path) = opts.walkthrough_file {
2687 crate::audit_brief::print_walkthrough_file_result(&result, path)
2688 } else if opts.brief {
2689 crate::audit_brief::print_brief_result(
2693 &result,
2694 opts.quiet,
2695 opts.explain,
2696 opts.show_deprioritized,
2697 )
2698 } else {
2699 print_audit_result(&result, opts.quiet, opts.explain)
2700 }
2701 }
2702 Err(code) => code,
2703 }
2704}
2705
2706#[must_use]
2715pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
2716 let brief_opts = AuditOptions {
2718 brief: true,
2719 ..*opts
2720 };
2721 match execute_audit(&brief_opts) {
2722 Ok(result) => crate::audit_brief::print_decision_surface_result(&result, opts.quiet),
2723 Err(code) => code,
2724 }
2725}
2726
2727#[cfg(test)]
2728mod tests {
2729 use super::*;
2730 use std::{fs, process::Command};
2731
2732 fn git(dir: &std::path::Path, args: &[&str]) {
2733 let output = Command::new("git")
2734 .args(args)
2735 .current_dir(dir)
2736 .env_remove("GIT_DIR")
2737 .env_remove("GIT_WORK_TREE")
2738 .env("GIT_CONFIG_GLOBAL", "/dev/null")
2739 .env("GIT_CONFIG_SYSTEM", "/dev/null")
2740 .env("GIT_AUTHOR_NAME", "test")
2741 .env("GIT_AUTHOR_EMAIL", "test@test.com")
2742 .env("GIT_COMMITTER_NAME", "test")
2743 .env("GIT_COMMITTER_EMAIL", "test@test.com")
2744 .output()
2745 .expect("git command failed");
2746 assert!(
2747 output.status.success(),
2748 "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
2749 args,
2750 String::from_utf8_lossy(&output.stdout),
2751 String::from_utf8_lossy(&output.stderr)
2752 );
2753 }
2754
2755 #[test]
2756 fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
2757 let temp = std::env::temp_dir();
2758 let audit_path = temp.join("fallow-audit-base-123-456");
2759 let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
2760 let canonical_audit_path = temp
2761 .canonicalize()
2762 .unwrap_or_else(|_| temp.clone())
2763 .join("fallow-audit-base-456-789");
2764 let unrelated_temp = temp.join("other-worktree");
2765 let output = format!(
2766 "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
2767 audit_path.display(),
2768 unrelated_temp.display(),
2769 reusable_path.display()
2770 );
2771
2772 assert_eq!(
2773 parse_worktree_list(&output),
2774 vec![audit_path, reusable_path.clone()]
2775 );
2776 assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
2777 assert!(is_reusable_audit_worktree_path(&reusable_path));
2778 assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
2779 assert_eq!(
2780 audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
2781 None
2782 );
2783 assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
2784 }
2785
2786 fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
2790 let root = parent.join(name);
2791 fs::create_dir_all(&root).expect("repo root should be created");
2792 fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
2793 git(&root, &["init", "-b", "main"]);
2794 git(&root, &["add", "."]);
2795 git(
2796 &root,
2797 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2798 );
2799 root
2800 }
2801
2802 fn commit_file(repo: &std::path::Path, name: &str, body: &str) -> String {
2804 fs::write(repo.join(name), body).expect("file should be written");
2805 git(repo, &["add", "."]);
2806 git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", name]);
2807 git_rev_parse(repo, "HEAD").expect("HEAD should resolve")
2808 }
2809
2810 #[test]
2811 fn auto_detect_base_ref_resolves_origin_default_to_merge_base() {
2812 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2813 let repo = init_throwaway_repo(tmp.path(), "repo");
2814 let head = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2815 git(&repo, &["branch", "trunk"]);
2816 git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
2817 git(
2818 &repo,
2819 &[
2820 "symbolic-ref",
2821 "refs/remotes/origin/HEAD",
2822 "refs/remotes/origin/trunk",
2823 ],
2824 );
2825
2826 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2827 assert_eq!(detected.git_ref, head);
2830 assert_eq!(detected.description, "merge-base with origin/trunk");
2831 }
2832
2833 #[test]
2838 fn auto_detect_base_ref_ignores_stale_local_main() {
2839 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2840 let repo = init_throwaway_repo(tmp.path(), "repo");
2841 let stale = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2842
2843 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
2845 git(
2846 &repo,
2847 &[
2848 "symbolic-ref",
2849 "refs/remotes/origin/HEAD",
2850 "refs/remotes/origin/main",
2851 ],
2852 );
2853 let fork_point = commit_file(&repo, "teammate.txt", "merged work\n");
2854 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
2855
2856 git(&repo, &["checkout", "-b", "feature", &fork_point]);
2859 commit_file(&repo, "feature.txt", "my change\n");
2860 git(&repo, &["branch", "-f", "main", &stale]);
2861
2862 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2863 assert_eq!(
2864 detected.git_ref, fork_point,
2865 "base must be the fork point (origin/main), not stale local main"
2866 );
2867 assert_ne!(
2868 detected.git_ref, stale,
2869 "must not diff against stale local main"
2870 );
2871 assert_eq!(detected.description, "merge-base with origin/main");
2872 }
2873
2874 #[test]
2875 fn auto_detect_base_ref_prefers_configured_upstream() {
2876 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2877 let repo = init_throwaway_repo(tmp.path(), "repo");
2878 let fork_point = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2879 git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
2882 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
2883
2884 git(&repo, &["checkout", "-b", "feature"]);
2885 git(
2886 &repo,
2887 &["branch", "--set-upstream-to=origin/main", "feature"],
2888 );
2889 commit_file(&repo, "feature.txt", "my change\n");
2890
2891 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2892 assert_eq!(detected.git_ref, fork_point);
2893 assert_eq!(detected.description, "merge-base with origin/main");
2894 }
2895
2896 #[test]
2897 fn auto_detect_base_ref_falls_back_to_local_main_without_remote() {
2898 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2899 let repo = init_throwaway_repo(tmp.path(), "repo");
2900
2901 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2902 assert_eq!(detected.git_ref, "main");
2903 assert_eq!(detected.description, "local main");
2904 }
2905
2906 #[test]
2907 fn auto_detect_base_ref_falls_back_to_local_master_without_remote() {
2908 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2909 let repo = tmp.path().join("repo");
2910 fs::create_dir_all(&repo).expect("repo root should be created");
2911 fs::write(repo.join("README.md"), "seed\n").expect("seed file should be written");
2912 git(&repo, &["init", "-b", "master"]);
2913 git(&repo, &["add", "."]);
2914 git(
2915 &repo,
2916 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2917 );
2918
2919 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2920 assert_eq!(detected.git_ref, "master");
2921 assert_eq!(detected.description, "local master");
2922 }
2923
2924 #[test]
2925 fn auto_detect_base_ref_returns_none_outside_git_repo() {
2926 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2927
2928 assert!(auto_detect_base_ref(tmp.path()).is_none());
2929 }
2930
2931 #[test]
2932 fn parse_audit_base_override_trims_and_rejects_empty() {
2933 assert_eq!(parse_audit_base_override(None), None);
2934 assert_eq!(parse_audit_base_override(Some(String::new())), None);
2935 assert_eq!(parse_audit_base_override(Some(" ".to_string())), None);
2936 assert_eq!(
2937 parse_audit_base_override(Some(" origin/main ".to_string())),
2938 Some("origin/main".to_string())
2939 );
2940 }
2941
2942 #[test]
2946 fn auto_detect_base_ref_falls_back_to_remote_tip_without_common_ancestor() {
2947 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2948 let repo = init_throwaway_repo(tmp.path(), "repo");
2949 git(&repo, &["checkout", "--orphan", "unrelated"]);
2952 commit_file(&repo, "unrelated.txt", "no shared history\n");
2953 let unrelated = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2954 git(
2955 &repo,
2956 &["update-ref", "refs/remotes/origin/main", &unrelated],
2957 );
2958 git(
2959 &repo,
2960 &[
2961 "symbolic-ref",
2962 "refs/remotes/origin/HEAD",
2963 "refs/remotes/origin/main",
2964 ],
2965 );
2966 git(&repo, &["checkout", "main"]);
2967
2968 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2969 assert_eq!(detected.git_ref, "origin/main");
2970 assert_eq!(detected.description, "origin/main (tip)");
2971 }
2972
2973 #[test]
2974 fn get_head_sha_returns_short_head_for_git_repo() {
2975 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2976 let repo = init_throwaway_repo(tmp.path(), "repo");
2977 let output = Command::new("git")
2978 .args(["rev-parse", "--short", "HEAD"])
2979 .current_dir(&repo)
2980 .env_remove("GIT_DIR")
2981 .env_remove("GIT_WORK_TREE")
2982 .output()
2983 .expect("git rev-parse should run");
2984 assert!(output.status.success());
2985
2986 assert_eq!(
2987 get_head_sha(&repo),
2988 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
2989 );
2990 }
2991
2992 #[test]
2993 fn get_head_sha_returns_none_outside_git_repo() {
2994 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2995
2996 assert_eq!(get_head_sha(tmp.path()), None);
2997 }
2998
2999 fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3000 list_audit_worktrees(repo_root)
3001 .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
3002 }
3003
3004 fn worktree_admin_entry_present(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3012 let basename = worktree_path
3013 .file_name()
3014 .and_then(|n| n.to_str())
3015 .expect("reusable worktree path has a utf-8 basename");
3016 let output = Command::new("git")
3017 .args(["worktree", "list", "--porcelain"])
3018 .current_dir(repo_root)
3019 .env_remove("GIT_DIR")
3020 .env_remove("GIT_WORK_TREE")
3021 .output()
3022 .expect("git worktree list should run");
3023 String::from_utf8_lossy(&output.stdout)
3024 .lines()
3025 .filter_map(|line| line.strip_prefix("worktree "))
3026 .any(|p| p.ends_with(basename))
3027 }
3028
3029 #[test]
3030 fn worktree_cleanup_guard_runs_on_drop() {
3031 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3032 let repo = init_throwaway_repo(tmp.path(), "repo");
3033 let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
3034
3035 git(
3036 &repo,
3037 &[
3038 "worktree",
3039 "add",
3040 "--detach",
3041 "--quiet",
3042 worktree_path.to_str().expect("path is utf-8"),
3043 "HEAD",
3044 ],
3045 );
3046 assert!(worktree_path.is_dir());
3047 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3048
3049 {
3050 let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3051 }
3052
3053 assert!(
3054 !worktree_path.exists(),
3055 "guard Drop should remove the worktree directory",
3056 );
3057 assert!(
3058 !worktree_is_registered_with_git(&repo, &worktree_path),
3059 "guard Drop should remove the git worktree registration",
3060 );
3061 }
3062
3063 #[test]
3064 fn worktree_cleanup_guard_defused_skips_drop() {
3065 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3066 let repo = init_throwaway_repo(tmp.path(), "repo");
3067 let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
3068
3069 git(
3070 &repo,
3071 &[
3072 "worktree",
3073 "add",
3074 "--detach",
3075 "--quiet",
3076 worktree_path.to_str().expect("path is utf-8"),
3077 "HEAD",
3078 ],
3079 );
3080 assert!(worktree_path.is_dir());
3081
3082 {
3083 let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3084 guard.defuse();
3085 guard.defuse();
3086 }
3087
3088 assert!(
3089 worktree_path.is_dir(),
3090 "defused guard must not remove the worktree on drop",
3091 );
3092 assert!(
3093 worktree_is_registered_with_git(&repo, &worktree_path),
3094 "defused guard must not unregister the worktree from git",
3095 );
3096
3097 remove_audit_worktree(&repo, &worktree_path);
3098 let _ = fs::remove_dir_all(&worktree_path);
3099 }
3100
3101 #[test]
3102 fn audit_orphan_sweep_removes_dead_pid_worktree() {
3103 const DEAD_PID: u32 = 99_999_999;
3104 assert!(!process_is_alive(DEAD_PID));
3105
3106 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3107 let repo = init_throwaway_repo(tmp.path(), "repo");
3108
3109 let worktree_path = std::env::temp_dir().join(format!(
3110 "fallow-audit-base-{}-{}",
3111 DEAD_PID,
3112 std::time::SystemTime::now()
3113 .duration_since(std::time::UNIX_EPOCH)
3114 .expect("clock should be after epoch")
3115 .as_nanos()
3116 ));
3117 git(
3118 &repo,
3119 &[
3120 "worktree",
3121 "add",
3122 "--detach",
3123 "--quiet",
3124 worktree_path.to_str().expect("path is utf-8"),
3125 "HEAD",
3126 ],
3127 );
3128 assert!(worktree_path.is_dir());
3129 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3130
3131 sweep_orphan_audit_worktrees(&repo);
3132
3133 assert!(
3134 !worktree_path.exists(),
3135 "sweep should remove worktree owned by a dead PID",
3136 );
3137 assert!(
3138 !worktree_is_registered_with_git(&repo, &worktree_path),
3139 "sweep should unregister worktree owned by a dead PID",
3140 );
3141 }
3142
3143 #[test]
3144 fn audit_orphan_sweep_keeps_live_pid_worktree() {
3145 let live_pid = std::process::id();
3146 assert!(process_is_alive(live_pid));
3147
3148 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3149 let repo = init_throwaway_repo(tmp.path(), "repo");
3150
3151 let worktree_path = std::env::temp_dir().join(format!(
3152 "fallow-audit-base-{}-{}",
3153 live_pid,
3154 std::time::SystemTime::now()
3155 .duration_since(std::time::UNIX_EPOCH)
3156 .expect("clock should be after epoch")
3157 .as_nanos()
3158 ));
3159 git(
3160 &repo,
3161 &[
3162 "worktree",
3163 "add",
3164 "--detach",
3165 "--quiet",
3166 worktree_path.to_str().expect("path is utf-8"),
3167 "HEAD",
3168 ],
3169 );
3170
3171 sweep_orphan_audit_worktrees(&repo);
3172
3173 assert!(
3174 worktree_path.is_dir(),
3175 "sweep must not remove worktree owned by a live PID",
3176 );
3177 assert!(
3178 worktree_is_registered_with_git(&repo, &worktree_path),
3179 "sweep must not unregister worktree owned by a live PID",
3180 );
3181
3182 remove_audit_worktree(&repo, &worktree_path);
3183 let _ = fs::remove_dir_all(&worktree_path);
3184 }
3185
3186 fn make_reusable_path(label: &str) -> PathBuf {
3190 let nanos = std::time::SystemTime::now()
3191 .duration_since(std::time::UNIX_EPOCH)
3192 .expect("clock should be after epoch")
3193 .as_nanos();
3194 std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
3195 }
3196
3197 fn register_reusable_worktree(repo: &Path, path: &Path) {
3201 git(
3202 repo,
3203 &[
3204 "worktree",
3205 "add",
3206 "--detach",
3207 "--quiet",
3208 path.to_str().expect("path is utf-8"),
3209 "HEAD",
3210 ],
3211 );
3212 }
3213
3214 fn write_sidecar_with_age(path: &Path, age: Duration) {
3215 let sidecar = reusable_worktree_last_used_path(path);
3216 let file = std::fs::OpenOptions::new()
3217 .create(true)
3218 .truncate(false)
3219 .write(true)
3220 .open(&sidecar)
3221 .expect("sidecar should open");
3222 let when = SystemTime::now()
3223 .checked_sub(age)
3224 .expect("backdated time should fit in SystemTime");
3225 file.set_modified(when)
3226 .expect("set_modified should succeed");
3227 }
3228
3229 fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
3232 remove_audit_worktree(repo, path);
3233 let _ = fs::remove_dir_all(path);
3234 let _ = fs::remove_file(reusable_worktree_last_used_path(path));
3235 let _ = fs::remove_file(reusable_worktree_lock_path(path));
3236 }
3237
3238 #[test]
3239 fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
3240 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3241 let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
3242 let worktree_path = make_reusable_path("gc-remove");
3243 register_reusable_worktree(&repo, &worktree_path);
3244 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3245
3246 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3247
3248 assert!(
3249 !worktree_path.exists(),
3250 "sweep should remove worktree dir whose sidecar is older than the threshold",
3251 );
3252 assert!(
3253 !worktree_is_registered_with_git(&repo, &worktree_path),
3254 "sweep should unregister the worktree from git",
3255 );
3256 assert!(
3257 !reusable_worktree_last_used_path(&worktree_path).exists(),
3258 "sweep should remove the sidecar `.last-used` file alongside the worktree",
3259 );
3260 cleanup_reusable_worktree(&repo, &worktree_path);
3261 }
3262
3263 #[test]
3264 fn reusable_cache_gc_keeps_fresh_entry() {
3265 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3266 let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
3267 let worktree_path = make_reusable_path("gc-keep");
3268 register_reusable_worktree(&repo, &worktree_path);
3269 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3270
3271 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3272
3273 assert!(
3274 worktree_path.is_dir(),
3275 "sweep must not remove a worktree whose sidecar is fresher than the threshold",
3276 );
3277 assert!(
3278 worktree_is_registered_with_git(&repo, &worktree_path),
3279 "sweep must not unregister a fresh worktree",
3280 );
3281 cleanup_reusable_worktree(&repo, &worktree_path);
3282 }
3283
3284 #[test]
3285 fn reusable_cache_gc_skips_locked_entry() {
3286 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3287 let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
3288 let worktree_path = make_reusable_path("gc-locked");
3289 register_reusable_worktree(&repo, &worktree_path);
3290 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3291
3292 let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
3293 .expect("test should acquire the lock first");
3294
3295 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3296
3297 assert!(
3298 worktree_path.is_dir(),
3299 "sweep must skip a locked entry even when its sidecar is stale",
3300 );
3301 assert!(
3302 worktree_is_registered_with_git(&repo, &worktree_path),
3303 "sweep must not unregister a locked entry",
3304 );
3305 drop(lock);
3306 cleanup_reusable_worktree(&repo, &worktree_path);
3307 }
3308
3309 #[test]
3310 fn reusable_cache_gc_grace_when_sidecar_absent() {
3311 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3312 let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
3313 let worktree_path = make_reusable_path("gc-grace");
3314 register_reusable_worktree(&repo, &worktree_path);
3315 let sidecar = reusable_worktree_last_used_path(&worktree_path);
3316 assert!(
3317 !sidecar.exists(),
3318 "test pre-condition: sidecar should not exist",
3319 );
3320
3321 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3322
3323 assert!(
3324 worktree_path.is_dir(),
3325 "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
3326 );
3327 assert!(
3328 sidecar.exists(),
3329 "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
3330 );
3331 let mtime = std::fs::metadata(&sidecar)
3332 .and_then(|m| m.modified())
3333 .expect("seeded sidecar should have a readable mtime");
3334 let age = SystemTime::now()
3335 .duration_since(mtime)
3336 .unwrap_or(Duration::ZERO);
3337 assert!(
3338 age < Duration::from_mins(1),
3339 "seeded sidecar mtime should be near `now()`, got age {age:?}",
3340 );
3341 cleanup_reusable_worktree(&repo, &worktree_path);
3342 }
3343
3344 #[test]
3345 fn reusable_cache_gc_reclaims_prunable_orphan_when_dir_missing() {
3346 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3347 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
3348 let worktree_path = make_reusable_path("gc-orphan");
3349 register_reusable_worktree(&repo, &worktree_path);
3350 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3353 let sidecar = reusable_worktree_last_used_path(&worktree_path);
3354
3355 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
3358 assert!(
3359 !worktree_path.exists(),
3360 "test pre-condition: cache dir should be gone",
3361 );
3362 assert!(
3363 worktree_admin_entry_present(&repo, &worktree_path),
3364 "test pre-condition: git admin entry should still be registered (prunable)",
3365 );
3366 assert!(
3367 sidecar.exists(),
3368 "test pre-condition: sidecar survives a dir-only reaper",
3369 );
3370
3371 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3372
3373 assert!(
3374 !worktree_admin_entry_present(&repo, &worktree_path),
3375 "sweep should unregister a prunable orphan whose dir was externally removed",
3376 );
3377 assert!(
3378 !sidecar.exists(),
3379 "sweep should remove the stale sidecar for a reclaimed orphan",
3380 );
3381 cleanup_reusable_worktree(&repo, &worktree_path);
3382 }
3383
3384 #[test]
3385 fn reusable_cache_gc_reclaims_prunable_orphan_even_when_age_gc_disabled() {
3386 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3387 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
3388 let worktree_path = make_reusable_path("gc-orphan-nogc");
3389 register_reusable_worktree(&repo, &worktree_path);
3390 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3391 let sidecar = reusable_worktree_last_used_path(&worktree_path);
3392 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
3393 assert!(
3394 worktree_admin_entry_present(&repo, &worktree_path),
3395 "test pre-condition: git admin entry should still be registered (prunable)",
3396 );
3397 assert!(
3398 sidecar.exists(),
3399 "test pre-condition: sidecar survives a dir-only reaper",
3400 );
3401
3402 sweep_old_reusable_caches(&repo, None, true);
3405
3406 assert!(
3407 !worktree_admin_entry_present(&repo, &worktree_path),
3408 "orphan reclaim must run even when age-based GC is disabled",
3409 );
3410 assert!(
3411 !sidecar.exists(),
3412 "sweep should remove the stale sidecar even when age-based GC is disabled",
3413 );
3414 cleanup_reusable_worktree(&repo, &worktree_path);
3415 }
3416
3417 #[test]
3418 fn reusable_cache_gc_preserves_lock_file_after_removal() {
3419 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3420 let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
3421 let worktree_path = make_reusable_path("gc-lockfile");
3422 register_reusable_worktree(&repo, &worktree_path);
3423 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3424 let lock_path = reusable_worktree_lock_path(&worktree_path);
3425 drop(
3426 ReusableWorktreeLock::try_acquire(&worktree_path)
3427 .expect("test should acquire the lock"),
3428 );
3429 assert!(
3430 lock_path.exists(),
3431 "test pre-condition: lock file should exist before sweep",
3432 );
3433
3434 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3435
3436 assert!(
3437 !worktree_path.exists(),
3438 "sweep should still remove the worktree directory",
3439 );
3440 assert!(
3441 lock_path.exists(),
3442 "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
3443 );
3444 let _ = fs::remove_file(&lock_path);
3445 cleanup_reusable_worktree(&repo, &worktree_path);
3446 }
3447
3448 #[test]
3449 fn reuse_or_create_stamps_sidecar_on_fresh_create() {
3450 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3451 let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
3452 let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
3453
3454 let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
3455 .expect("fresh reuse_or_create should succeed on a clean repo");
3456 let cache_path = worktree.path().to_path_buf();
3457 let sidecar = reusable_worktree_last_used_path(&cache_path);
3458
3459 assert!(
3460 sidecar.exists(),
3461 "fresh-create must write the sidecar so age is measured from now",
3462 );
3463 let initial_age = std::fs::metadata(&sidecar)
3464 .and_then(|m| m.modified())
3465 .ok()
3466 .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
3467 .expect("sidecar mtime should be readable and not in the future");
3468 assert!(
3469 initial_age < Duration::from_mins(1),
3470 "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
3471 );
3472
3473 drop(worktree);
3474 cleanup_reusable_worktree(&repo, &cache_path);
3475 }
3476
3477 #[test]
3478 fn days_to_duration_zero_disables() {
3479 assert!(days_to_duration(0).is_none());
3480 assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
3481 assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
3482 }
3483
3484 #[test]
3485 fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
3486 let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
3487 let sidecar = reusable_worktree_last_used_path(&cache_dir);
3488 assert_eq!(sidecar.parent(), cache_dir.parent());
3489 assert_eq!(
3490 sidecar.file_name().and_then(|s| s.to_str()),
3491 Some("fallow-audit-base-cache-abcd-1234.last-used"),
3492 );
3493 }
3494
3495 #[test]
3496 fn touch_last_used_creates_sidecar_if_missing() {
3497 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3498 let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
3499 fs::create_dir(&cache_dir).expect("cache dir should be created");
3500 let sidecar = reusable_worktree_last_used_path(&cache_dir);
3501 assert!(!sidecar.exists(), "sidecar should not exist before touch");
3502
3503 touch_last_used(&cache_dir);
3504
3505 assert!(sidecar.exists(), "touch should create the sidecar");
3506 let mtime = fs::metadata(&sidecar)
3507 .and_then(|m| m.modified())
3508 .expect("sidecar should have an mtime");
3509 let age = SystemTime::now()
3510 .duration_since(mtime)
3511 .unwrap_or(Duration::ZERO);
3512 assert!(
3513 age < Duration::from_mins(1),
3514 "touched sidecar should be near `now()`",
3515 );
3516 }
3517
3518 #[test]
3519 fn reusable_worktree_lock_excludes_concurrent_acquires() {
3520 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3521 let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
3522 let lock_path = reusable_worktree_lock_path(&reusable);
3523
3524 let first = ReusableWorktreeLock::try_acquire(&reusable)
3525 .expect("first acquire on a fresh path should succeed");
3526 assert!(
3527 ReusableWorktreeLock::try_acquire(&reusable).is_none(),
3528 "second acquire must fail while the first is held",
3529 );
3530 drop(first);
3531 assert!(
3532 lock_path.exists(),
3533 "lock file must persist after drop (only the kernel lock is released)",
3534 );
3535 }
3536
3537 #[test]
3538 fn base_analysis_root_preserves_repo_subdirectory_roots() {
3539 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3540 let repo = tmp.path().join("repo");
3541 let app_root = repo.join("apps/mobile");
3542 let base_worktree = tmp.path().join("base-worktree");
3543 fs::create_dir_all(&app_root).expect("app root should be created");
3544 fs::create_dir_all(&base_worktree).expect("base worktree should be created");
3545 git(&repo, &["init", "-b", "main"]);
3546
3547 assert_eq!(
3548 base_analysis_root(&app_root, &base_worktree),
3549 base_worktree.join("apps/mobile")
3550 );
3551 }
3552
3553 #[test]
3554 fn audit_base_worktree_reuses_current_node_modules_context() {
3555 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3556 let root = tmp.path();
3557 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3558 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
3559 .expect("gitignore should be written");
3560 fs::write(
3561 root.join("package.json"),
3562 r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
3563 )
3564 .expect("package.json should be written");
3565 fs::write(
3566 root.join("tsconfig.json"),
3567 r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
3568 )
3569 .expect("tsconfig should be written");
3570 fs::write(
3571 root.join("src/index.ts"),
3572 "import { used } from '@/feature';\nconsole.log(used);\n",
3573 )
3574 .expect("index should be written");
3575 fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
3576 .expect("feature should be written");
3577
3578 git(root, &["init", "-b", "main"]);
3579 git(root, &["add", "."]);
3580 git(
3581 root,
3582 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3583 );
3584
3585 let rn_config = root.join("node_modules/@react-native/typescript-config");
3586 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
3587 fs::write(
3588 rn_config.join("tsconfig.json"),
3589 r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
3590 )
3591 .expect("node_modules tsconfig should be written");
3592
3593 let worktree =
3594 BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
3595 assert!(
3596 worktree.path().join("node_modules").is_dir(),
3597 "base worktree should reuse ignored node_modules from the current checkout"
3598 );
3599 assert!(
3600 worktree
3601 .path()
3602 .join("node_modules/@react-native/typescript-config/tsconfig.json")
3603 .is_file(),
3604 "base worktree should preserve tsconfig extends targets installed in node_modules"
3605 );
3606 }
3607
3608 #[test]
3618 fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
3619 let host = tempfile::TempDir::new().expect("host tempdir should be created");
3620 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3621
3622 let dot_nuxt = host.path().join(".nuxt");
3623 fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
3624 fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
3625 .expect(".nuxt/tsconfig.json should be written");
3626 fs::write(
3627 dot_nuxt.join("tsconfig.app.json"),
3628 r#"{"compilerOptions":{}}"#,
3629 )
3630 .expect(".nuxt/tsconfig.app.json should be written");
3631
3632 materialize_base_dependency_context(host.path(), worktree.path());
3633
3634 let mirrored = worktree.path().join(".nuxt");
3635 assert!(
3636 mirrored.is_dir(),
3637 "base worktree should reuse the ignored .nuxt dir from the host checkout"
3638 );
3639 let link_meta = fs::symlink_metadata(&mirrored)
3640 .expect(".nuxt entry should exist as a symlink in the worktree");
3641 assert!(
3642 link_meta.file_type().is_symlink(),
3643 "base worktree's .nuxt should be a symlink to the host checkout"
3644 );
3645 assert!(
3646 mirrored.join("tsconfig.json").is_file(),
3647 "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
3648 prerequisite check stays quiet"
3649 );
3650 assert!(
3651 mirrored.join("tsconfig.app.json").is_file(),
3652 "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
3653 resolve without falling back to resolver-less resolution"
3654 );
3655 }
3656
3657 #[test]
3662 fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
3663 let host = tempfile::TempDir::new().expect("host tempdir should be created");
3664 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3665
3666 let dot_astro = host.path().join(".astro");
3667 fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
3668 fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
3669 .expect(".astro/types.d.ts should be written");
3670
3671 materialize_base_dependency_context(host.path(), worktree.path());
3672
3673 let mirrored = worktree.path().join(".astro");
3674 assert!(
3675 mirrored.is_dir(),
3676 "base worktree should reuse the ignored .astro dir from the host checkout"
3677 );
3678 assert!(
3679 mirrored.join("types.d.ts").is_file(),
3680 "base worktree should expose generated Astro types so the Astro meta-framework \
3681 prerequisite check stays quiet"
3682 );
3683 }
3684
3685 #[test]
3692 fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
3693 let host = tempfile::TempDir::new().expect("host tempdir should be created");
3694 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3695
3696 materialize_base_dependency_context(host.path(), worktree.path());
3697
3698 assert!(
3699 !worktree.path().join(".nuxt").exists(),
3700 "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
3701 );
3702 assert!(
3703 !worktree.path().join(".astro").exists(),
3704 "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
3705 );
3706 assert!(
3707 !worktree.path().join("node_modules").exists(),
3708 "base worktree should not fabricate a node_modules symlink when the host has none"
3709 );
3710 }
3711
3712 #[test]
3716 fn materialize_base_dependency_context_handles_each_dir_independently() {
3717 let host = tempfile::TempDir::new().expect("host tempdir should be created");
3718 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3719
3720 fs::create_dir_all(host.path().join("node_modules"))
3721 .expect("host node_modules should be created");
3722
3723 materialize_base_dependency_context(host.path(), worktree.path());
3724
3725 assert!(
3726 worktree.path().join("node_modules").is_dir(),
3727 "node_modules should still be symlinked even when host has no .nuxt or .astro"
3728 );
3729 assert!(
3730 !worktree.path().join(".nuxt").exists(),
3731 "missing host .nuxt should leave the worktree slot empty"
3732 );
3733 }
3734
3735 #[test]
3742 fn materialize_base_dependency_context_preserves_real_worktree_dir() {
3743 let host = tempfile::TempDir::new().expect("host tempdir should be created");
3744 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3745
3746 let host_nuxt = host.path().join(".nuxt");
3747 fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
3748 fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
3749 .expect("host .nuxt/tsconfig.json should be written");
3750
3751 let worktree_nuxt = worktree.path().join(".nuxt");
3752 fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
3753 fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
3754 .expect("worktree .nuxt/tsconfig.json should be written");
3755
3756 materialize_base_dependency_context(host.path(), worktree.path());
3757
3758 let link_meta = fs::symlink_metadata(&worktree_nuxt)
3759 .expect(".nuxt entry should still exist in the worktree");
3760 assert!(
3761 !link_meta.file_type().is_symlink(),
3762 "a real base-tracked .nuxt dir must not be replaced by a host symlink"
3763 );
3764 let contents =
3765 fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
3766 assert!(
3767 contents.contains("base"),
3768 "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
3769 );
3770 }
3771
3772 #[test]
3773 fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
3774 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3775 let root = tmp.path();
3776 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
3777 .expect("gitignore should be written");
3778 fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
3779 .expect("package.json should be written");
3780
3781 git(root, &["init", "-b", "main"]);
3782 git(root, &["add", "."]);
3783 git(
3784 root,
3785 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3786 );
3787
3788 let rn_config = root.join("node_modules/@react-native/typescript-config");
3789 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
3790 fs::write(rn_config.join("tsconfig.json"), "{}")
3791 .expect("node_modules tsconfig should be written");
3792
3793 let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
3794 let first = BaseWorktree::reuse_or_create(root, &base_sha)
3795 .expect("persistent base worktree should be created");
3796 let worktree_path = first.path().to_path_buf();
3797 assert!(
3798 worktree_path.join("node_modules").is_dir(),
3799 "initial persistent worktree should receive node_modules context"
3800 );
3801 remove_node_modules_context(&worktree_path);
3802 assert!(
3803 !worktree_path.join("node_modules").exists(),
3804 "test setup should remove the dependency context from the reusable worktree"
3805 );
3806 drop(first);
3807
3808 let reused = BaseWorktree::reuse_or_create(root, &base_sha)
3809 .expect("ready persistent base worktree should be reused");
3810 assert_eq!(reused.path(), worktree_path.as_path());
3811 assert!(
3812 reused.path().join("node_modules").is_dir(),
3813 "ready persistent worktree should refresh missing node_modules context"
3814 );
3815
3816 cleanup_reusable_worktree(root, reused.path());
3817 }
3818
3819 fn remove_node_modules_context(worktree_path: &Path) {
3820 let path = worktree_path.join("node_modules");
3821 let Ok(metadata) = fs::symlink_metadata(&path) else {
3822 return;
3823 };
3824 if metadata.file_type().is_symlink() {
3825 #[cfg(unix)]
3826 let _ = fs::remove_file(path);
3827 #[cfg(windows)]
3828 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
3829 } else {
3830 let _ = fs::remove_dir_all(path);
3831 }
3832 }
3833
3834 #[test]
3835 fn audit_base_snapshot_cache_payload_roundtrips_sets() {
3836 let key = AuditBaseSnapshotCacheKey {
3837 hash: 42,
3838 base_sha: "abc123".to_string(),
3839 };
3840 let snapshot = AuditKeySnapshot {
3841 dead_code: ["dead:a".to_string(), "dead:b".to_string()]
3842 .into_iter()
3843 .collect(),
3844 health: std::iter::once("health:a".to_string()).collect(),
3845 dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
3846 .into_iter()
3847 .collect(),
3848 boundary_edges: std::iter::once("ui->-db".to_string()).collect(),
3849 cycles: std::iter::once("a.ts|b.ts".to_string()).collect(),
3850 public_api: std::iter::once("src/index.ts::foo".to_string()).collect(),
3851 };
3852
3853 let cached = cached_from_snapshot(&key, &snapshot);
3854 assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
3855 assert_eq!(cached.key_hash, key.hash);
3856 assert_eq!(cached.base_sha, key.base_sha);
3857 assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
3858
3859 let decoded = snapshot_from_cached(cached);
3860 assert_eq!(decoded.dead_code, snapshot.dead_code);
3861 assert_eq!(decoded.health, snapshot.health);
3862 assert_eq!(decoded.dupes, snapshot.dupes);
3863 assert_eq!(decoded.boundary_edges, snapshot.boundary_edges);
3864 assert_eq!(decoded.cycles, snapshot.cycles);
3865 assert_eq!(decoded.public_api, snapshot.public_api);
3866 }
3867
3868 #[test]
3869 fn audit_base_snapshot_cache_dir_writes_gitignore() {
3870 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3871 let cache_root = tmp.path().join(".custom-fallow-cache");
3872 let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
3873
3874 ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
3875
3876 assert_eq!(
3877 fs::read_to_string(cache_dir.join(".gitignore")).expect("gitignore should read"),
3878 "*\n"
3879 );
3880 }
3881
3882 #[test]
3883 fn audit_base_snapshot_cache_roundtrips_from_disk() {
3884 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3885 let config_path = None;
3886 let cache_root = tmp.path().join(".custom-fallow-cache");
3887 let opts = AuditOptions {
3888 root: tmp.path(),
3889 cache_dir: &cache_root,
3890 config_path: &config_path,
3891 output: OutputFormat::Json,
3892 no_cache: false,
3893 threads: 1,
3894 quiet: true,
3895 changed_since: Some("HEAD"),
3896 production: false,
3897 production_dead_code: None,
3898 production_health: None,
3899 production_dupes: None,
3900 workspace: None,
3901 changed_workspaces: None,
3902 explain: false,
3903 explain_skipped: false,
3904 performance: false,
3905 group_by: None,
3906 dead_code_baseline: None,
3907 health_baseline: None,
3908 dupes_baseline: None,
3909 max_crap: None,
3910 coverage: None,
3911 coverage_root: None,
3912 gate: AuditGate::NewOnly,
3913 include_entry_exports: false,
3914 runtime_coverage: None,
3915 min_invocations_hot: 100,
3916 brief: false,
3917 max_decisions: 4,
3918 walkthrough_guide: false,
3919 walkthrough_file: None,
3920 show_deprioritized: false,
3921 };
3922 let key = AuditBaseSnapshotCacheKey {
3923 hash: 0xfeed,
3924 base_sha: "abc123".to_string(),
3925 };
3926 let snapshot = AuditKeySnapshot {
3927 dead_code: std::iter::once("dead:a".to_string()).collect(),
3928 health: std::iter::once("health:a".to_string()).collect(),
3929 dupes: std::iter::once("dupe:a".to_string()).collect(),
3930 boundary_edges: FxHashSet::default(),
3931 cycles: FxHashSet::default(),
3932 public_api: FxHashSet::default(),
3933 };
3934
3935 save_cached_base_snapshot(&opts, &key, &snapshot);
3936 assert!(
3937 audit_base_snapshot_cache_file(&cache_root, &key).exists(),
3938 "snapshot should be saved below the configured cache directory"
3939 );
3940 let loaded = load_cached_base_snapshot(&opts, &key).expect("snapshot should load");
3941
3942 assert_eq!(loaded.dead_code, snapshot.dead_code);
3943 assert_eq!(loaded.health, snapshot.health);
3944 assert_eq!(loaded.dupes, snapshot.dupes);
3945 }
3946
3947 #[test]
3948 fn audit_base_snapshot_cache_rejects_mismatched_key() {
3949 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3950 let config_path = None;
3951 let cache_root = tmp.path().join(".custom-fallow-cache");
3952 let opts = AuditOptions {
3953 root: tmp.path(),
3954 cache_dir: &cache_root,
3955 config_path: &config_path,
3956 output: OutputFormat::Json,
3957 no_cache: false,
3958 threads: 1,
3959 quiet: true,
3960 changed_since: Some("HEAD"),
3961 production: false,
3962 production_dead_code: None,
3963 production_health: None,
3964 production_dupes: None,
3965 workspace: None,
3966 changed_workspaces: None,
3967 explain: false,
3968 explain_skipped: false,
3969 performance: false,
3970 group_by: None,
3971 dead_code_baseline: None,
3972 health_baseline: None,
3973 dupes_baseline: None,
3974 max_crap: None,
3975 coverage: None,
3976 coverage_root: None,
3977 gate: AuditGate::NewOnly,
3978 include_entry_exports: false,
3979 runtime_coverage: None,
3980 min_invocations_hot: 100,
3981 brief: false,
3982 max_decisions: 4,
3983 walkthrough_guide: false,
3984 walkthrough_file: None,
3985 show_deprioritized: false,
3986 };
3987 let key = AuditBaseSnapshotCacheKey {
3988 hash: 0xbeef,
3989 base_sha: "head".to_string(),
3990 };
3991 let cached = CachedAuditKeySnapshot {
3992 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
3993 cli_version: env!("CARGO_PKG_VERSION").to_string(),
3994 key_hash: key.hash,
3995 base_sha: "other".to_string(),
3996 dead_code: vec!["dead:a".to_string()],
3997 health: vec![],
3998 dupes: vec![],
3999 boundary_edges: vec![],
4000 cycles: vec![],
4001 public_api: vec![],
4002 };
4003 let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
4004 ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
4005 fs::write(
4006 audit_base_snapshot_cache_file(&cache_root, &key),
4007 bitcode::encode(&cached),
4008 )
4009 .expect("cache file should be written");
4010
4011 assert!(load_cached_base_snapshot(&opts, &key).is_none());
4012 }
4013
4014 #[test]
4015 fn audit_base_snapshot_cache_key_includes_extended_config() {
4016 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4017 let root = tmp.path();
4018 fs::write(
4019 root.join(".fallowrc.json"),
4020 r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
4021 )
4022 .expect("config should be written");
4023 fs::write(
4024 root.join("base.json"),
4025 r#"{"rules":{"unused-exports":"off"}}"#,
4026 )
4027 .expect("base config should be written");
4028
4029 let config_path = None;
4030 let cache_root = root.join(".fallow");
4031 let opts = AuditOptions {
4032 root,
4033 cache_dir: &cache_root,
4034 config_path: &config_path,
4035 output: OutputFormat::Json,
4036 no_cache: false,
4037 threads: 1,
4038 quiet: true,
4039 changed_since: Some("HEAD"),
4040 production: false,
4041 production_dead_code: None,
4042 production_health: None,
4043 production_dupes: None,
4044 workspace: None,
4045 changed_workspaces: None,
4046 explain: false,
4047 explain_skipped: false,
4048 performance: false,
4049 group_by: None,
4050 dead_code_baseline: None,
4051 health_baseline: None,
4052 dupes_baseline: None,
4053 max_crap: None,
4054 coverage: None,
4055 coverage_root: None,
4056 gate: AuditGate::NewOnly,
4057 include_entry_exports: false,
4058 runtime_coverage: None,
4059 min_invocations_hot: 100,
4060 brief: false,
4061 max_decisions: 4,
4062 walkthrough_guide: false,
4063 walkthrough_file: None,
4064 show_deprioritized: false,
4065 };
4066
4067 let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
4068 fs::write(
4069 root.join("base.json"),
4070 r#"{"rules":{"unused-exports":"error"}}"#,
4071 )
4072 .expect("base config should be updated");
4073 let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
4074
4075 assert_ne!(
4076 first["resolved_hash"], second["resolved_hash"],
4077 "extended config changes must invalidate cached base snapshots"
4078 );
4079 }
4080
4081 #[test]
4082 fn audit_gate_all_skips_base_snapshot() {
4083 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4084 let root = tmp.path();
4085 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4086 fs::write(
4087 root.join("package.json"),
4088 r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
4089 )
4090 .expect("package.json should be written");
4091 fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
4092 .expect("index should be written");
4093
4094 git(root, &["init", "-b", "main"]);
4095 git(root, &["add", "."]);
4096 git(
4097 root,
4098 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4099 );
4100 fs::write(
4101 root.join("src/index.ts"),
4102 "export const legacy = 1;\nexport const changed = 2;\n",
4103 )
4104 .expect("changed module should be written");
4105
4106 let config_path = None;
4107 let cache_root = root.join(".fallow");
4108 let opts = AuditOptions {
4109 root,
4110 cache_dir: &cache_root,
4111 config_path: &config_path,
4112 output: OutputFormat::Json,
4113 no_cache: true,
4114 threads: 1,
4115 quiet: true,
4116 changed_since: Some("HEAD"),
4117 production: false,
4118 production_dead_code: None,
4119 production_health: None,
4120 production_dupes: None,
4121 workspace: None,
4122 changed_workspaces: None,
4123 explain: false,
4124 explain_skipped: false,
4125 performance: false,
4126 group_by: None,
4127 dead_code_baseline: None,
4128 health_baseline: None,
4129 dupes_baseline: None,
4130 max_crap: None,
4131 coverage: None,
4132 coverage_root: None,
4133 gate: AuditGate::All,
4134 include_entry_exports: false,
4135 runtime_coverage: None,
4136 min_invocations_hot: 100,
4137 brief: false,
4138 max_decisions: 4,
4139 walkthrough_guide: false,
4140 walkthrough_file: None,
4141 show_deprioritized: false,
4142 };
4143
4144 let result = execute_audit(&opts).expect("audit should execute");
4145 assert!(result.base_snapshot.is_none());
4146 assert_eq!(result.attribution.gate, AuditGate::All);
4147 assert_eq!(result.attribution.dead_code_introduced, 0);
4148 assert_eq!(result.attribution.dead_code_inherited, 0);
4149 }
4150
4151 #[test]
4152 fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
4153 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4154 let root = tmp.path();
4155 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4156 fs::write(
4157 root.join("package.json"),
4158 r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
4159 )
4160 .expect("package.json should be written");
4161 fs::write(
4162 root.join(".fallowrc.json"),
4163 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
4164 )
4165 .expect("config should be written");
4166 let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
4167 fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
4168 fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
4169 fs::write(root.join("README.md"), "before\n").expect("readme should be written");
4170
4171 git(root, &["init", "-b", "main"]);
4172 git(root, &["add", "."]);
4173 git(
4174 root,
4175 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4176 );
4177 fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
4178 fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
4179 .expect("cache dir should be created");
4180 fs::write(
4181 root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
4182 b"cache",
4183 )
4184 .expect("cache artifact should be written");
4185
4186 let before_worktrees = audit_worktree_names(root);
4187
4188 let config_path = None;
4189 let cache_root = root.join(".fallow");
4190 let opts = AuditOptions {
4191 root,
4192 cache_dir: &cache_root,
4193 config_path: &config_path,
4194 output: OutputFormat::Json,
4195 no_cache: true,
4196 threads: 1,
4197 quiet: true,
4198 changed_since: Some("HEAD"),
4199 production: false,
4200 production_dead_code: None,
4201 production_health: None,
4202 production_dupes: None,
4203 workspace: None,
4204 changed_workspaces: None,
4205 explain: false,
4206 explain_skipped: false,
4207 performance: true,
4208 group_by: None,
4209 dead_code_baseline: None,
4210 health_baseline: None,
4211 dupes_baseline: None,
4212 max_crap: None,
4213 coverage: None,
4214 coverage_root: None,
4215 gate: AuditGate::NewOnly,
4216 include_entry_exports: false,
4217 runtime_coverage: None,
4218 min_invocations_hot: 100,
4219 brief: false,
4220 max_decisions: 4,
4221 walkthrough_guide: false,
4222 walkthrough_file: None,
4223 show_deprioritized: false,
4224 };
4225
4226 let result = execute_audit(&opts).expect("audit should execute");
4227 assert_eq!(result.verdict, AuditVerdict::Pass);
4228 assert_eq!(result.changed_files_count, 2);
4229 assert!(result.base_snapshot_skipped);
4230 assert!(result.base_snapshot.is_some());
4231
4232 let after_worktrees = audit_worktree_names(root);
4233 assert_eq!(
4234 before_worktrees, after_worktrees,
4235 "base snapshot skip must not create a temporary base worktree"
4236 );
4237 }
4238
4239 fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
4240 let mut names: Vec<String> = list_audit_worktrees(repo_root)
4241 .unwrap_or_default()
4242 .into_iter()
4243 .filter_map(|path| {
4244 path.file_name()
4245 .and_then(|name| name.to_str())
4246 .map(str::to_owned)
4247 })
4248 .collect();
4249 names.sort();
4250 names
4251 }
4252
4253 #[test]
4254 fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
4255 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4256 let root = tmp.path();
4257 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4258 fs::write(
4259 root.join("package.json"),
4260 r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
4261 )
4262 .expect("package.json should be written");
4263 fs::write(
4264 root.join("src/index.ts"),
4265 "import { used } from './used';\nused();\n",
4266 )
4267 .expect("index should be written");
4268 fs::write(
4269 root.join("src/used.ts"),
4270 "export function used() {\n return 1;\n}\n",
4271 )
4272 .expect("used module should be written");
4273
4274 git(root, &["init", "-b", "main"]);
4275 git(root, &["add", "."]);
4276 git(
4277 root,
4278 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4279 );
4280 fs::write(
4281 root.join("src/used.ts"),
4282 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
4283 )
4284 .expect("changed module should be written");
4285
4286 let config_path = None;
4287 let cache_root = root.join(".fallow");
4288 let opts = AuditOptions {
4289 root,
4290 cache_dir: &cache_root,
4291 config_path: &config_path,
4292 output: OutputFormat::Json,
4293 no_cache: true,
4294 threads: 1,
4295 quiet: true,
4296 changed_since: Some("HEAD"),
4297 production: false,
4298 production_dead_code: None,
4299 production_health: None,
4300 production_dupes: None,
4301 workspace: None,
4302 changed_workspaces: None,
4303 explain: false,
4304 explain_skipped: false,
4305 performance: true,
4306 group_by: None,
4307 dead_code_baseline: None,
4308 health_baseline: None,
4309 dupes_baseline: None,
4310 max_crap: None,
4311 coverage: None,
4312 coverage_root: None,
4313 gate: AuditGate::NewOnly,
4314 include_entry_exports: false,
4315 runtime_coverage: None,
4316 min_invocations_hot: 100,
4317 brief: false,
4318 max_decisions: 4,
4319 walkthrough_guide: false,
4320 walkthrough_file: None,
4321 show_deprioritized: false,
4322 };
4323
4324 let result = execute_audit(&opts).expect("audit should execute");
4325 let health = result.health.expect("health should run for changed files");
4326 let timings = health.timings.expect("performance timings should be kept");
4327 assert!(timings.discover_ms.abs() < f64::EPSILON);
4328 assert!(timings.parse_ms.abs() < f64::EPSILON);
4329 assert!(
4330 result.dupes.is_some(),
4331 "dupes should run when changed files exist"
4332 );
4333 }
4334
4335 #[test]
4336 fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
4337 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4338 let root = tmp.path();
4339 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4340 fs::write(
4341 root.join("package.json"),
4342 r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
4343 )
4344 .expect("package.json should be written");
4345 fs::write(
4346 root.join("src/index.ts"),
4347 "import { used } from './used';\nused();\n",
4348 )
4349 .expect("index should be written");
4350 fs::write(
4351 root.join("src/used.ts"),
4352 "export function used() {\n return 1;\n}\n",
4353 )
4354 .expect("used module should be written");
4355
4356 git(root, &["init", "-b", "main"]);
4357 git(root, &["add", "."]);
4358 git(
4359 root,
4360 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4361 );
4362 fs::write(
4363 root.join("src/used.ts"),
4364 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
4365 )
4366 .expect("changed module should be written");
4367
4368 let config_path = None;
4369 let cache_root = root.join(".fallow");
4370 let opts = AuditOptions {
4371 root,
4372 cache_dir: &cache_root,
4373 config_path: &config_path,
4374 output: OutputFormat::Json,
4375 no_cache: true,
4376 threads: 1,
4377 quiet: true,
4378 changed_since: Some("HEAD"),
4379 production: false,
4380 production_dead_code: Some(true),
4381 production_health: Some(false),
4382 production_dupes: Some(false),
4383 workspace: None,
4384 changed_workspaces: None,
4385 explain: false,
4386 explain_skipped: false,
4387 performance: true,
4388 group_by: None,
4389 dead_code_baseline: None,
4390 health_baseline: None,
4391 dupes_baseline: None,
4392 max_crap: None,
4393 coverage: None,
4394 coverage_root: None,
4395 gate: AuditGate::NewOnly,
4396 include_entry_exports: false,
4397 runtime_coverage: None,
4398 min_invocations_hot: 100,
4399 brief: false,
4400 max_decisions: 4,
4401 walkthrough_guide: false,
4402 walkthrough_file: None,
4403 show_deprioritized: false,
4404 };
4405
4406 let result = execute_audit(&opts).expect("audit should execute");
4407 assert!(result.dupes.is_some(), "dupes should still run");
4408 }
4409
4410 #[cfg(unix)]
4411 #[test]
4412 fn remap_focus_files_does_not_canonicalize_through_symlinks() {
4413 let tmp = tempfile::TempDir::new().expect("temp dir");
4414 let real = tmp.path().join("real");
4415 let link = tmp.path().join("link");
4416 fs::create_dir_all(&real).expect("real dir");
4417 std::os::unix::fs::symlink(&real, &link).expect("symlink");
4418 let canonical = link.canonicalize().expect("canonicalize symlink");
4419 assert_ne!(link, canonical, "symlink should not equal its target");
4420
4421 let from_root = PathBuf::from("/repo");
4422 let mut focus = FxHashSet::default();
4423 focus.insert(from_root.join("src/foo.ts"));
4424
4425 let remapped = remap_focus_files(&focus, &from_root, &link)
4426 .expect("remap should succeed for in-prefix files");
4427
4428 let expected = link.join("src/foo.ts");
4429 assert!(
4430 remapped.contains(&expected),
4431 "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
4432 );
4433 }
4434
4435 #[test]
4436 fn remap_focus_files_skips_paths_outside_from_root() {
4437 let from_root = PathBuf::from("/repo/apps/web");
4438 let to_root = PathBuf::from("/wt/apps/web");
4439 let mut focus = FxHashSet::default();
4440 focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
4441 focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
4442
4443 let remapped =
4444 remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
4445
4446 assert_eq!(remapped.len(), 1);
4447 assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
4448 }
4449
4450 #[test]
4451 fn remap_focus_files_returns_none_when_no_paths_map() {
4452 let from_root = PathBuf::from("/repo/apps/web");
4453 let to_root = PathBuf::from("/wt/apps/web");
4454 let mut focus = FxHashSet::default();
4455 focus.insert(PathBuf::from("/elsewhere/foo.ts"));
4456
4457 let remapped = remap_focus_files(&focus, &from_root, &to_root);
4458 assert!(
4459 remapped.is_none(),
4460 "remap should return None when no paths can be mapped, falling caller back to full corpus"
4461 );
4462 }
4463
4464 #[test]
4465 fn remap_cache_dir_moves_project_local_cache_to_base_worktree() {
4466 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4467 let current_root = tmp.path().join("repo");
4468 let base_root = tmp.path().join("fallow-base");
4469 let cache_dir = current_root.join(".cache").join("fallow");
4470
4471 let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
4472
4473 assert_eq!(remapped, base_root.join(".cache").join("fallow"));
4474 }
4475
4476 #[test]
4477 fn remap_cache_dir_keeps_external_absolute_cache_shared() {
4478 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4479 let current_root = tmp.path().join("repo");
4480 let base_root = tmp.path().join("fallow-base");
4481 let cache_dir = tmp.path().join("shared").join("fallow-cache");
4482
4483 let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
4484
4485 assert_eq!(remapped, cache_dir);
4486 }
4487
4488 #[test]
4489 fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
4490 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4491 let root_buf = tmp
4492 .path()
4493 .canonicalize()
4494 .expect("temp root should canonicalize");
4495 let root = root_buf.as_path();
4496 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4497 fs::write(
4498 root.join("package.json"),
4499 r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
4500 )
4501 .expect("package.json should be written");
4502 fs::write(
4503 root.join(".fallowrc.json"),
4504 r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
4505 )
4506 .expect("config should be written");
4507
4508 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";
4509 fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
4510 fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
4511
4512 git(root, &["init", "-b", "main"]);
4513 git(root, &["add", "."]);
4514 git(
4515 root,
4516 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4517 );
4518 fs::write(
4519 root.join("src/changed.ts"),
4520 format!("{dup_block}// touched\n"),
4521 )
4522 .expect("changed file should be modified");
4523 git(root, &["add", "."]);
4524 git(
4525 root,
4526 &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
4527 );
4528
4529 let config_path = None;
4530 let cache_root = root.join(".fallow");
4531 let opts = AuditOptions {
4532 root,
4533 cache_dir: &cache_root,
4534 config_path: &config_path,
4535 output: OutputFormat::Json,
4536 no_cache: true,
4537 threads: 1,
4538 quiet: true,
4539 changed_since: Some("HEAD~1"),
4540 production: false,
4541 production_dead_code: None,
4542 production_health: None,
4543 production_dupes: None,
4544 workspace: None,
4545 changed_workspaces: None,
4546 explain: false,
4547 explain_skipped: false,
4548 performance: false,
4549 group_by: None,
4550 dead_code_baseline: None,
4551 health_baseline: None,
4552 dupes_baseline: None,
4553 max_crap: None,
4554 coverage: None,
4555 coverage_root: None,
4556 gate: AuditGate::NewOnly,
4557 include_entry_exports: false,
4558 runtime_coverage: None,
4559 min_invocations_hot: 100,
4560 brief: false,
4561 max_decisions: 4,
4562 walkthrough_guide: false,
4563 walkthrough_file: None,
4564 show_deprioritized: false,
4565 };
4566
4567 let result = execute_audit(&opts).expect("audit should execute");
4568 assert!(
4569 result.base_snapshot_skipped,
4570 "comment-only JS/TS diffs should reuse current keys as the base snapshot"
4571 );
4572 let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
4573 assert!(
4574 !dupes_report.clone_groups.is_empty(),
4575 "current run should detect the pre-existing duplicate"
4576 );
4577 assert_eq!(
4578 result.attribution.duplication_introduced, 0,
4579 "pre-existing duplicate must not be classified as introduced; \
4580 attribution = {:?}",
4581 result.attribution
4582 );
4583 assert!(
4584 result.attribution.duplication_inherited > 0,
4585 "pre-existing duplicate must be classified as inherited; \
4586 attribution = {:?}",
4587 result.attribution
4588 );
4589 }
4590
4591 #[test]
4592 #[expect(
4593 clippy::too_many_lines,
4594 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4595 )]
4596 fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
4597 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4598 let root = tmp.path();
4599 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4600 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4601 .expect("node_modules config dir should be created");
4602 fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
4603 fs::write(
4604 root.join("package.json"),
4605 r#"{
4606 "name": "audit-react-native-tsconfig-base",
4607 "private": true,
4608 "main": "src/App.tsx",
4609 "dependencies": {
4610 "react-native": "0.80.0"
4611 }
4612 }"#,
4613 )
4614 .expect("package.json should be written");
4615 fs::write(
4616 root.join("tsconfig.json"),
4617 r#"{
4618 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4619 "compilerOptions": {
4620 "baseUrl": ".",
4621 "paths": {
4622 "@/*": ["src/*"]
4623 }
4624 },
4625 "include": ["src/**/*"]
4626 }"#,
4627 )
4628 .expect("tsconfig should be written");
4629 fs::write(
4630 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4631 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4632 )
4633 .expect("react native tsconfig should be written");
4634 fs::write(
4635 root.join("src/App.tsx"),
4636 r#"import { homeTitle } from "@/screens/Home";
4637
4638export function App() {
4639 return homeTitle;
4640}
4641"#,
4642 )
4643 .expect("app should be written");
4644 fs::write(
4645 root.join("src/screens/Home.ts"),
4646 r#"export const homeTitle = "home";
4647"#,
4648 )
4649 .expect("home should be written");
4650
4651 git(root, &["init", "-b", "main"]);
4652 git(root, &["add", "."]);
4653 git(
4654 root,
4655 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4656 );
4657 fs::write(
4658 root.join("src/App.tsx"),
4659 r#"import { homeTitle } from "@/screens/Home";
4660
4661export function App() {
4662 return homeTitle.toUpperCase();
4663}
4664"#,
4665 )
4666 .expect("app should be modified");
4667
4668 let config_path = None;
4669 let cache_root = root.join(".fallow");
4670 let opts = AuditOptions {
4671 root,
4672 cache_dir: &cache_root,
4673 config_path: &config_path,
4674 output: OutputFormat::Json,
4675 no_cache: true,
4676 threads: 1,
4677 quiet: true,
4678 changed_since: Some("HEAD"),
4679 production: false,
4680 production_dead_code: None,
4681 production_health: None,
4682 production_dupes: None,
4683 workspace: None,
4684 changed_workspaces: None,
4685 explain: false,
4686 explain_skipped: false,
4687 performance: false,
4688 group_by: None,
4689 dead_code_baseline: None,
4690 health_baseline: None,
4691 dupes_baseline: None,
4692 max_crap: None,
4693 coverage: None,
4694 coverage_root: None,
4695 gate: AuditGate::NewOnly,
4696 include_entry_exports: false,
4697 runtime_coverage: None,
4698 min_invocations_hot: 100,
4699 brief: false,
4700 max_decisions: 4,
4701 walkthrough_guide: false,
4702 walkthrough_file: None,
4703 show_deprioritized: false,
4704 };
4705
4706 let result = execute_audit(&opts).expect("audit should execute");
4707 assert!(
4708 !result.base_snapshot_skipped,
4709 "source diffs should run a real base snapshot"
4710 );
4711 let base = result
4712 .base_snapshot
4713 .as_ref()
4714 .expect("base snapshot should run");
4715 assert!(
4716 !base
4717 .dead_code
4718 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
4719 "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
4720 base.dead_code
4721 );
4722 assert!(
4723 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
4724 "alias target should stay reachable in the base worktree: {:?}",
4725 base.dead_code
4726 );
4727 let check = result.check.as_ref().expect("dead-code audit should run");
4728 assert!(
4729 check.results.unresolved_imports.is_empty(),
4730 "HEAD audit should also resolve @/* aliases: {:?}",
4731 check.results.unresolved_imports
4732 );
4733 }
4734
4735 #[test]
4736 #[expect(
4737 clippy::too_many_lines,
4738 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4739 )]
4740 fn audit_base_preserves_subdirectory_root_resolution() {
4741 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4742 let repo = tmp.path().join("repo");
4743 let root = repo.join("apps/mobile");
4744 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4745 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4746 .expect("node_modules config dir should be created");
4747 fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
4748 .expect("gitignore should be written");
4749 fs::write(
4750 root.join("package.json"),
4751 r#"{
4752 "name": "audit-subdir-react-native-tsconfig-base",
4753 "private": true,
4754 "main": "src/App.tsx",
4755 "dependencies": {
4756 "react-native": "0.80.0"
4757 }
4758 }"#,
4759 )
4760 .expect("package.json should be written");
4761 fs::write(
4762 root.join("tsconfig.json"),
4763 r#"{
4764 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4765 "compilerOptions": {
4766 "baseUrl": ".",
4767 "paths": {
4768 "@/*": ["src/*"]
4769 }
4770 },
4771 "include": ["src/**/*"]
4772 }"#,
4773 )
4774 .expect("tsconfig should be written");
4775 fs::write(
4776 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4777 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4778 )
4779 .expect("react native tsconfig should be written");
4780 fs::write(
4781 root.join("src/App.tsx"),
4782 r#"import { homeTitle } from "@/screens/Home";
4783
4784export function App() {
4785 return homeTitle;
4786}
4787"#,
4788 )
4789 .expect("app should be written");
4790 fs::write(
4791 root.join("src/screens/Home.ts"),
4792 r#"export const homeTitle = "home";
4793"#,
4794 )
4795 .expect("home should be written");
4796
4797 git(&repo, &["init", "-b", "main"]);
4798 git(&repo, &["add", "."]);
4799 git(
4800 &repo,
4801 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4802 );
4803 fs::write(
4804 root.join("src/App.tsx"),
4805 r#"import { homeTitle } from "@/screens/Home";
4806
4807export function App() {
4808 return homeTitle.toUpperCase();
4809}
4810"#,
4811 )
4812 .expect("app should be modified");
4813
4814 let config_path = None;
4815 let cache_root = root.join(".fallow");
4816 let opts = AuditOptions {
4817 root: &root,
4818 cache_dir: &cache_root,
4819 config_path: &config_path,
4820 output: OutputFormat::Json,
4821 no_cache: true,
4822 threads: 1,
4823 quiet: true,
4824 changed_since: Some("HEAD"),
4825 production: false,
4826 production_dead_code: None,
4827 production_health: None,
4828 production_dupes: None,
4829 workspace: None,
4830 changed_workspaces: None,
4831 explain: false,
4832 explain_skipped: false,
4833 performance: false,
4834 group_by: None,
4835 dead_code_baseline: None,
4836 health_baseline: None,
4837 dupes_baseline: None,
4838 max_crap: None,
4839 coverage: None,
4840 coverage_root: None,
4841 gate: AuditGate::NewOnly,
4842 include_entry_exports: false,
4843 runtime_coverage: None,
4844 min_invocations_hot: 100,
4845 brief: false,
4846 max_decisions: 4,
4847 walkthrough_guide: false,
4848 walkthrough_file: None,
4849 show_deprioritized: false,
4850 };
4851
4852 let result = execute_audit(&opts).expect("audit should execute");
4853 assert!(
4854 !result.base_snapshot_skipped,
4855 "source diffs should run a real base snapshot"
4856 );
4857 let base = result
4858 .base_snapshot
4859 .as_ref()
4860 .expect("base snapshot should run");
4861 assert!(
4862 !base
4863 .dead_code
4864 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
4865 "base audit should analyze from the app subdirectory, not the repo root: {:?}",
4866 base.dead_code
4867 );
4868 assert!(
4869 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
4870 "subdirectory base audit should keep alias targets reachable: {:?}",
4871 base.dead_code
4872 );
4873 }
4874
4875 #[test]
4876 fn audit_base_uses_new_explicit_config_without_hard_failure() {
4877 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4878 let root = tmp.path();
4879 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4880 fs::write(
4881 root.join("package.json"),
4882 r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
4883 )
4884 .expect("package.json should be written");
4885 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
4886 .expect("index should be written");
4887
4888 git(root, &["init", "-b", "main"]);
4889 git(root, &["add", "."]);
4890 git(
4891 root,
4892 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4893 );
4894
4895 let explicit_config = root.join(".fallowrc.json");
4896 fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
4897 .expect("new config should be written");
4898 fs::write(root.join("src/index.ts"), "export const used = 2;\n")
4899 .expect("index should be modified");
4900
4901 let config_path = Some(explicit_config);
4902 let cache_root = root.join(".fallow");
4903 let opts = AuditOptions {
4904 root,
4905 cache_dir: &cache_root,
4906 config_path: &config_path,
4907 output: OutputFormat::Json,
4908 no_cache: true,
4909 threads: 1,
4910 quiet: true,
4911 changed_since: Some("HEAD"),
4912 production: false,
4913 production_dead_code: None,
4914 production_health: None,
4915 production_dupes: None,
4916 workspace: None,
4917 changed_workspaces: None,
4918 explain: false,
4919 explain_skipped: false,
4920 performance: false,
4921 group_by: None,
4922 dead_code_baseline: None,
4923 health_baseline: None,
4924 dupes_baseline: None,
4925 max_crap: None,
4926 coverage: None,
4927 coverage_root: None,
4928 gate: AuditGate::NewOnly,
4929 include_entry_exports: false,
4930 runtime_coverage: None,
4931 min_invocations_hot: 100,
4932 brief: false,
4933 max_decisions: 4,
4934 walkthrough_guide: false,
4935 walkthrough_file: None,
4936 show_deprioritized: false,
4937 };
4938
4939 let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
4940 assert!(
4941 result.base_snapshot.is_some(),
4942 "base snapshot should use the current explicit config even when the base commit lacks it"
4943 );
4944 }
4945
4946 #[test]
4947 fn audit_base_uses_current_discovered_config_for_attribution() {
4948 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4949 let root = tmp.path();
4950 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4951 fs::write(
4952 root.join("package.json"),
4953 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
4954 )
4955 .expect("package.json should be written");
4956 fs::write(
4957 root.join(".fallowrc.json"),
4958 r#"{"rules":{"unused-dependencies":"off"}}"#,
4959 )
4960 .expect("base config should be written");
4961 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
4962 .expect("index should be written");
4963
4964 git(root, &["init", "-b", "main"]);
4965 git(root, &["add", "."]);
4966 git(
4967 root,
4968 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4969 );
4970
4971 fs::write(
4972 root.join(".fallowrc.json"),
4973 r#"{"rules":{"unused-dependencies":"error"}}"#,
4974 )
4975 .expect("current config should be written");
4976 fs::write(
4977 root.join("package.json"),
4978 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
4979 )
4980 .expect("package.json should be touched");
4981
4982 let config_path = None;
4983 let cache_root = root.join(".fallow");
4984 let opts = AuditOptions {
4985 root,
4986 cache_dir: &cache_root,
4987 config_path: &config_path,
4988 output: OutputFormat::Json,
4989 no_cache: true,
4990 threads: 1,
4991 quiet: true,
4992 changed_since: Some("HEAD"),
4993 production: false,
4994 production_dead_code: None,
4995 production_health: None,
4996 production_dupes: None,
4997 workspace: None,
4998 changed_workspaces: None,
4999 explain: false,
5000 explain_skipped: false,
5001 performance: false,
5002 group_by: None,
5003 dead_code_baseline: None,
5004 health_baseline: None,
5005 dupes_baseline: None,
5006 max_crap: None,
5007 coverage: None,
5008 coverage_root: None,
5009 gate: AuditGate::NewOnly,
5010 include_entry_exports: false,
5011 runtime_coverage: None,
5012 min_invocations_hot: 100,
5013 brief: false,
5014 max_decisions: 4,
5015 walkthrough_guide: false,
5016 walkthrough_file: None,
5017 show_deprioritized: false,
5018 };
5019
5020 let result = execute_audit(&opts).expect("audit should execute");
5021 assert_eq!(
5022 result.attribution.dead_code_introduced, 0,
5023 "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
5024 result.attribution
5025 );
5026 assert!(
5027 result.attribution.dead_code_inherited > 0,
5028 "pre-existing changed-file findings should be classified as inherited: {:?}",
5029 result.attribution
5030 );
5031 }
5032
5033 #[test]
5034 fn audit_base_current_config_attribution_survives_cache_hit() {
5035 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5036 let root = tmp.path();
5037 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5038 fs::write(
5039 root.join("package.json"),
5040 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5041 )
5042 .expect("package.json should be written");
5043 fs::write(
5044 root.join(".fallowrc.json"),
5045 r#"{"rules":{"unused-dependencies":"off"}}"#,
5046 )
5047 .expect("base config should be written");
5048 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5049 .expect("index should be written");
5050
5051 git(root, &["init", "-b", "main"]);
5052 git(root, &["add", "."]);
5053 git(
5054 root,
5055 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5056 );
5057
5058 fs::write(
5059 root.join(".fallowrc.json"),
5060 r#"{"rules":{"unused-dependencies":"error"}}"#,
5061 )
5062 .expect("current config should be written");
5063 fs::write(
5064 root.join("package.json"),
5065 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5066 )
5067 .expect("package.json should be touched");
5068
5069 let config_path = None;
5070 let cache_root = root.join(".fallow");
5071 let opts = AuditOptions {
5072 root,
5073 cache_dir: &cache_root,
5074 config_path: &config_path,
5075 output: OutputFormat::Json,
5076 no_cache: false,
5077 threads: 1,
5078 quiet: true,
5079 changed_since: Some("HEAD"),
5080 production: false,
5081 production_dead_code: None,
5082 production_health: None,
5083 production_dupes: None,
5084 workspace: None,
5085 changed_workspaces: None,
5086 explain: false,
5087 explain_skipped: false,
5088 performance: false,
5089 group_by: None,
5090 dead_code_baseline: None,
5091 health_baseline: None,
5092 dupes_baseline: None,
5093 max_crap: None,
5094 coverage: None,
5095 coverage_root: None,
5096 gate: AuditGate::NewOnly,
5097 include_entry_exports: false,
5098 runtime_coverage: None,
5099 min_invocations_hot: 100,
5100 brief: false,
5101 max_decisions: 4,
5102 walkthrough_guide: false,
5103 walkthrough_file: None,
5104 show_deprioritized: false,
5105 };
5106
5107 let first = execute_audit(&opts).expect("first audit should execute");
5108 assert_eq!(
5109 first.attribution.dead_code_introduced, 0,
5110 "first audit should classify pre-existing findings as inherited: {:?}",
5111 first.attribution
5112 );
5113
5114 let changed_files =
5115 crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
5116 let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
5117 .expect("cache key should compute")
5118 .expect("cache key should exist");
5119 assert!(
5120 load_cached_base_snapshot(&opts, &key).is_some(),
5121 "first audit should store a reusable base snapshot"
5122 );
5123
5124 let second = execute_audit(&opts).expect("second audit should execute");
5125 assert_eq!(
5126 second.attribution.dead_code_introduced, 0,
5127 "cache hit should keep current-config attribution stable: {:?}",
5128 second.attribution
5129 );
5130 assert!(
5131 second.attribution.dead_code_inherited > 0,
5132 "cache hit should preserve inherited base findings: {:?}",
5133 second.attribution
5134 );
5135 }
5136
5137 #[test]
5138 fn audit_dupes_only_materializes_groups_touching_changed_files() {
5139 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5140 let root_path = tmp
5141 .path()
5142 .canonicalize()
5143 .expect("temp root should canonicalize");
5144 let root = root_path.as_path();
5145 fs::create_dir_all(root.join("src")).expect("src dir should be created");
5146 fs::write(
5147 root.join("package.json"),
5148 r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
5149 )
5150 .expect("package.json should be written");
5151 fs::write(
5152 root.join(".fallowrc.json"),
5153 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
5154 )
5155 .expect("config should be written");
5156
5157 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";
5158 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";
5159 fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
5160 fs::write(root.join("src/focused-copy.ts"), focused_code)
5161 .expect("focused copy should be written");
5162 fs::write(root.join("src/untouched-a.ts"), untouched_code)
5163 .expect("untouched a should be written");
5164 fs::write(root.join("src/untouched-b.ts"), untouched_code)
5165 .expect("untouched b should be written");
5166
5167 git(root, &["init", "-b", "main"]);
5168 git(root, &["add", "."]);
5169 git(
5170 root,
5171 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5172 );
5173 fs::write(
5174 root.join("src/changed.ts"),
5175 format!("{focused_code}export const changedMarker = true;\n"),
5176 )
5177 .expect("changed file should be modified");
5178
5179 let config_path = None;
5180 let cache_root = root.join(".fallow");
5181 let opts = AuditOptions {
5182 root,
5183 cache_dir: &cache_root,
5184 config_path: &config_path,
5185 output: OutputFormat::Json,
5186 no_cache: true,
5187 threads: 1,
5188 quiet: true,
5189 changed_since: Some("HEAD"),
5190 production: false,
5191 production_dead_code: None,
5192 production_health: None,
5193 production_dupes: None,
5194 workspace: None,
5195 changed_workspaces: None,
5196 explain: false,
5197 explain_skipped: false,
5198 performance: false,
5199 group_by: None,
5200 dead_code_baseline: None,
5201 health_baseline: None,
5202 dupes_baseline: None,
5203 max_crap: None,
5204 coverage: None,
5205 coverage_root: None,
5206 gate: AuditGate::All,
5207 include_entry_exports: false,
5208 runtime_coverage: None,
5209 min_invocations_hot: 100,
5210 brief: false,
5211 max_decisions: 4,
5212 walkthrough_guide: false,
5213 walkthrough_file: None,
5214 show_deprioritized: false,
5215 };
5216
5217 let result = execute_audit(&opts).expect("audit should execute");
5218 let dupes = result.dupes.expect("dupes should run");
5219 let changed_path = root.join("src/changed.ts");
5220
5221 assert!(
5222 !dupes.report.clone_groups.is_empty(),
5223 "changed file should still match unchanged duplicate code"
5224 );
5225 assert!(dupes.report.clone_groups.iter().all(|group| {
5226 group
5227 .instances
5228 .iter()
5229 .any(|instance| instance.file == changed_path)
5230 }));
5231 }
5232
5233 #[test]
5236 fn tokens_equivalent_whitespace_only() {
5237 let a = "export const x = 1;\nexport const y = 2;\n";
5239 let b = "export const x = 1;\n\n\nexport const y = 2;\n";
5240 assert!(
5241 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5242 "whitespace-only change must be treated as equivalent"
5243 );
5244 }
5245
5246 #[test]
5247 fn tokens_equivalent_comment_only_change() {
5248 let a = "export const x = 1;\n";
5251 let b = "// note\nexport const x = 1;\n";
5252 assert!(
5253 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5254 "comment-only change must be treated as equivalent (comments emit no tokens)"
5255 );
5256 }
5257
5258 #[test]
5259 fn tokens_equivalent_identifier_rename_is_not_equivalent() {
5260 let a = "export const a = 1;\n";
5262 let b = "export const b = 1;\n";
5263 assert!(
5264 !js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5265 "identifier rename must be treated as non-equivalent"
5266 );
5267 }
5268
5269 #[test]
5270 fn tokens_equivalent_string_literal_change_is_not_equivalent() {
5271 let a = r#"import x from "./a";"#;
5273 let b = r#"import x from "./b";"#;
5274 assert!(
5275 !js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5276 "string-literal change must be treated as non-equivalent"
5277 );
5278 }
5279
5280 #[test]
5281 fn tokens_equivalent_fallow_ignore_marker_forces_false() {
5282 let code = "// fallow-ignore-next-line unused-exports\nexport const x = 1;\n";
5285 assert!(
5286 !js_ts_tokens_equivalent(Path::new("a.ts"), code, code),
5287 "fallow-ignore marker in either side must force false"
5288 );
5289 }
5290
5291 #[test]
5292 fn tokens_equivalent_non_js_extension_is_false() {
5293 let a = ".foo { color: red; }\n";
5295 let b = ".foo {\n color: red;\n}\n";
5296 assert!(
5297 !js_ts_tokens_equivalent(Path::new("styles.css"), a, b),
5298 "non-JS/TS extension must always return false"
5299 );
5300 }
5301
5302 #[test]
5311 fn tokens_equivalent_template_literal_content_change_is_equivalent_known_gap() {
5312 let a = "const p = import(`./pages/${x}`);\n";
5313 let b = "const p = import(`./views/${x}`);\n";
5314 assert!(
5319 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5320 "template-literal content change is CURRENTLY treated as equivalent (known gap)"
5321 );
5322 }
5323
5324 #[test]
5327 fn tokens_equivalent_regex_literal_content_change_is_equivalent_known_gap() {
5328 let a = "const re = /^foo/;\n";
5329 let b = "const re = /^bar/;\n";
5330 assert!(
5332 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5333 "regex-literal content change is CURRENTLY treated as equivalent (known gap)"
5334 );
5335 }
5336
5337 #[test]
5338 fn analysis_input_and_doc_classification() {
5339 assert!(is_analysis_input(Path::new("src/app.ts")));
5341 assert!(is_analysis_input(Path::new("src/app.tsx")));
5342 assert!(is_analysis_input(Path::new("src/app.js")));
5343 assert!(is_analysis_input(Path::new("src/app.jsx")));
5344 assert!(is_analysis_input(Path::new("src/app.mts")));
5345 assert!(is_analysis_input(Path::new("src/app.vue")));
5346 assert!(is_analysis_input(Path::new("src/styles.css")));
5347
5348 assert!(!is_analysis_input(Path::new("README.md")));
5350 assert!(!is_analysis_input(Path::new("package.json")));
5351 assert!(!is_analysis_input(Path::new("image.png")));
5352
5353 assert!(is_non_behavioral_doc(Path::new("README.md")));
5355 assert!(is_non_behavioral_doc(Path::new("CHANGELOG.txt")));
5356 assert!(is_non_behavioral_doc(Path::new("docs/guide.rst")));
5357 assert!(is_non_behavioral_doc(Path::new("docs/guide.adoc")));
5358
5359 assert!(!is_analysis_input(Path::new("package.json")));
5362 assert!(!is_non_behavioral_doc(Path::new("package.json")));
5363 }
5364}