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