1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitCode};
4use std::time::{Duration, Instant};
5
6use fallow_config::{AuditGate, OutputFormat};
7use fallow_core::git_env::clear_ambient_git_env;
8use rustc_hash::FxHashSet;
9use xxhash_rust::xxh3::xxh3_64;
10
11use crate::base_worktree::{
12 BaseWorktree, git_rev_parse, git_toplevel, resolve_cache_max_age, sweep_old_reusable_caches,
13};
14use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
15use crate::dupes::{DupesMode, DupesOptions, DupesResult};
16use crate::error::emit_error;
17use crate::health::{HealthOptions, HealthResult, SortBy};
18
19const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 2;
20const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[serde(rename_all = "snake_case")]
26pub enum AuditVerdict {
27 Pass,
29 Warn,
31 Fail,
33}
34
35#[derive(Debug, Clone, serde::Serialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38pub struct AuditSummary {
39 pub dead_code_issues: usize,
40 pub dead_code_has_errors: bool,
41 pub complexity_findings: usize,
42 pub max_cyclomatic: Option<u16>,
43 pub duplication_clone_groups: usize,
44}
45
46#[derive(Debug, Default, Clone, serde::Serialize)]
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49pub struct AuditAttribution {
50 pub gate: AuditGate,
51 pub dead_code_introduced: usize,
52 pub dead_code_inherited: usize,
53 pub complexity_introduced: usize,
54 pub complexity_inherited: usize,
55 pub duplication_introduced: usize,
56 pub duplication_inherited: usize,
57}
58
59pub struct AuditResult {
61 pub verdict: AuditVerdict,
62 pub summary: AuditSummary,
63 pub attribution: AuditAttribution,
64 base_snapshot: Option<AuditKeySnapshot>,
65 pub base_snapshot_skipped: bool,
66 pub changed_files_count: usize,
67 pub changed_files: Vec<PathBuf>,
71 pub base_ref: String,
72 pub base_description: Option<String>,
77 pub head_sha: Option<String>,
78 pub output: OutputFormat,
79 pub performance: bool,
80 pub check: Option<CheckResult>,
81 pub dupes: Option<DupesResult>,
82 pub health: Option<HealthResult>,
83 pub elapsed: Duration,
84}
85
86pub struct AuditOptions<'a> {
87 pub root: &'a std::path::Path,
88 pub config_path: &'a Option<std::path::PathBuf>,
89 pub cache_dir: &'a std::path::Path,
90 pub output: OutputFormat,
91 pub no_cache: bool,
92 pub threads: usize,
93 pub quiet: bool,
94 pub changed_since: Option<&'a str>,
95 pub production: bool,
96 pub production_dead_code: Option<bool>,
97 pub production_health: Option<bool>,
98 pub production_dupes: Option<bool>,
99 pub workspace: Option<&'a [String]>,
100 pub changed_workspaces: Option<&'a str>,
101 pub explain: bool,
102 pub explain_skipped: bool,
103 pub performance: bool,
104 pub group_by: Option<crate::GroupBy>,
105 pub dead_code_baseline: Option<&'a std::path::Path>,
107 pub health_baseline: Option<&'a std::path::Path>,
109 pub dupes_baseline: Option<&'a std::path::Path>,
111 pub max_crap: Option<f64>,
114 pub coverage: Option<&'a std::path::Path>,
116 pub coverage_root: Option<&'a std::path::Path>,
118 pub gate: AuditGate,
119 pub include_entry_exports: bool,
121 pub runtime_coverage: Option<&'a std::path::Path>,
127 pub min_invocations_hot: u64,
129}
130
131struct DetectedBase {
134 git_ref: String,
137 description: String,
140}
141
142fn git_stdout(root: &std::path::Path, args: &[&str]) -> Option<String> {
145 let mut command = std::process::Command::new("git");
146 command.args(args).current_dir(root);
147 clear_ambient_git_env(&mut command);
148 let output = command.output().ok()?;
149 if !output.status.success() {
150 return None;
151 }
152 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
153 if trimmed.is_empty() {
154 None
155 } else {
156 Some(trimmed)
157 }
158}
159
160fn git_ref_exists(root: &std::path::Path, git_ref: &str) -> bool {
162 git_stdout(root, &["rev-parse", "--verify", "--quiet", git_ref]).is_some()
163}
164
165fn git_upstream_ref(root: &std::path::Path) -> Option<String> {
168 git_stdout(
169 root,
170 &[
171 "rev-parse",
172 "--abbrev-ref",
173 "--symbolic-full-name",
174 "@{upstream}",
175 ],
176 )
177}
178
179fn git_merge_base(root: &std::path::Path, a: &str, b: &str) -> Option<String> {
182 git_stdout(root, &["merge-base", a, b])
183}
184
185fn detect_remote_default_ref(root: &std::path::Path) -> Option<String> {
189 if let Some(full_ref) = git_stdout(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
190 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
191 {
192 return Some(format!("origin/{branch}"));
193 }
194 for candidate in ["origin/main", "origin/master"] {
195 if git_ref_exists(root, candidate) {
196 return Some(candidate.to_string());
197 }
198 }
199 None
200}
201
202fn auto_detect_base_ref(root: &std::path::Path) -> Option<DetectedBase> {
224 if let Some(upstream) = git_upstream_ref(root) {
225 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
226 return Some(DetectedBase {
227 git_ref: sha,
228 description: format!("merge-base with {upstream}"),
229 });
230 }
231 return Some(DetectedBase {
234 description: format!("{upstream} (tip)"),
235 git_ref: upstream,
236 });
237 }
238
239 if let Some(remote_ref) = detect_remote_default_ref(root) {
240 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
241 return Some(DetectedBase {
242 git_ref: sha,
243 description: format!("merge-base with {remote_ref}"),
244 });
245 }
246 return Some(DetectedBase {
247 description: format!("{remote_ref} (tip)"),
248 git_ref: remote_ref,
249 });
250 }
251
252 for candidate in ["main", "master"] {
253 if git_ref_exists(root, candidate) {
254 return Some(DetectedBase {
255 git_ref: candidate.to_string(),
256 description: format!("local {candidate}"),
257 });
258 }
259 }
260
261 None
262}
263
264fn get_head_sha(root: &std::path::Path) -> Option<String> {
266 let mut command = std::process::Command::new("git");
267 command
268 .args(["rev-parse", "--short", "HEAD"])
269 .current_dir(root);
270 clear_ambient_git_env(&mut command);
271 let output = command.output().ok()?;
272 if output.status.success() {
273 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
274 } else {
275 None
276 }
277}
278
279fn compute_verdict(
280 check: Option<&CheckResult>,
281 dupes: Option<&DupesResult>,
282 health: Option<&HealthResult>,
283) -> AuditVerdict {
284 let mut has_errors = false;
285 let mut has_warnings = false;
286
287 if let Some(result) = check {
288 if crate::check::has_error_severity_issues(
289 &result.results,
290 &result.config.rules,
291 Some(&result.config),
292 ) {
293 has_errors = true;
294 } else if result.results.total_issues() > 0 {
295 has_warnings = true;
296 }
297 }
298
299 if let Some(result) = health
300 && !result.report.findings.is_empty()
301 {
302 has_errors = true;
303 }
304
305 if let Some(result) = dupes
306 && !result.report.clone_groups.is_empty()
307 {
308 if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
309 has_errors = true;
310 } else {
311 has_warnings = true;
312 }
313 }
314
315 if has_errors {
316 AuditVerdict::Fail
317 } else if has_warnings {
318 AuditVerdict::Warn
319 } else {
320 AuditVerdict::Pass
321 }
322}
323
324fn build_summary(
325 check: Option<&CheckResult>,
326 dupes: Option<&DupesResult>,
327 health: Option<&HealthResult>,
328) -> AuditSummary {
329 let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
330 let dead_code_has_errors = check.is_some_and(|r| {
331 crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
332 });
333 let complexity_findings = health.map_or(0, |r| r.report.findings.len());
334 let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
335 let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
336
337 AuditSummary {
338 dead_code_issues,
339 dead_code_has_errors,
340 complexity_findings,
341 max_cyclomatic,
342 duplication_clone_groups,
343 }
344}
345
346fn compute_audit_attribution(
347 check: Option<&CheckResult>,
348 dupes: Option<&DupesResult>,
349 health: Option<&HealthResult>,
350 base: Option<&AuditKeySnapshot>,
351 gate: AuditGate,
352) -> AuditAttribution {
353 let dead_code = check
354 .map(|r| {
355 count_introduced(
356 &dead_code_keys(&r.results, &r.config.root),
357 base.map(|b| &b.dead_code),
358 )
359 })
360 .unwrap_or_default();
361 let complexity = health
362 .map(|r| {
363 count_introduced(
364 &health_keys(&r.report, &r.config.root),
365 base.map(|b| &b.health),
366 )
367 })
368 .unwrap_or_default();
369 let duplication = dupes
370 .map(|r| {
371 count_introduced(
372 &dupes_keys(&r.report, &r.config.root),
373 base.map(|b| &b.dupes),
374 )
375 })
376 .unwrap_or_default();
377
378 AuditAttribution {
379 gate,
380 dead_code_introduced: dead_code.0,
381 dead_code_inherited: dead_code.1,
382 complexity_introduced: complexity.0,
383 complexity_inherited: complexity.1,
384 duplication_introduced: duplication.0,
385 duplication_inherited: duplication.1,
386 }
387}
388
389fn compute_introduced_verdict(
390 check: Option<&CheckResult>,
391 dupes: Option<&DupesResult>,
392 health: Option<&HealthResult>,
393 base: Option<&AuditKeySnapshot>,
394) -> AuditVerdict {
395 let mut has_errors = false;
396 let mut has_warnings = false;
397
398 if let Some(result) = check {
399 let base_keys = base.map(|b| &b.dead_code);
400 let mut introduced = result.results.clone();
401 retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
402 if crate::check::has_error_severity_issues(
403 &introduced,
404 &result.config.rules,
405 Some(&result.config),
406 ) {
407 has_errors = true;
408 } else if introduced.total_issues() > 0 {
409 has_warnings = true;
410 }
411 }
412
413 if let Some(result) = health {
414 let base_keys = base.map(|b| &b.health);
415 let introduced = result
416 .report
417 .findings
418 .iter()
419 .filter(|finding| {
420 !base_keys.is_some_and(|keys| {
421 keys.contains(&health_finding_key(finding, &result.config.root))
422 })
423 })
424 .count();
425 if introduced > 0 {
426 has_errors = true;
427 }
428 }
429
430 if let Some(result) = dupes {
431 let base_keys = base.map(|b| &b.dupes);
432 let introduced = result
433 .report
434 .clone_groups
435 .iter()
436 .filter(|group| {
437 !base_keys
438 .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
439 })
440 .count();
441 if introduced > 0 {
442 if result.threshold > 0.0
443 && result.report.stats.duplication_percentage > result.threshold
444 {
445 has_errors = true;
446 } else {
447 has_warnings = true;
448 }
449 }
450 }
451
452 if has_errors {
453 AuditVerdict::Fail
454 } else if has_warnings {
455 AuditVerdict::Warn
456 } else {
457 AuditVerdict::Pass
458 }
459}
460
461struct AuditKeySnapshot {
462 dead_code: FxHashSet<String>,
463 health: FxHashSet<String>,
464 dupes: FxHashSet<String>,
465}
466
467struct AuditBaseSnapshotCacheKey {
468 hash: u64,
469 base_sha: String,
470}
471
472#[derive(bitcode::Encode, bitcode::Decode)]
473struct CachedAuditKeySnapshot {
474 version: u8,
475 cli_version: String,
476 key_hash: u64,
477 base_sha: String,
478 dead_code: Vec<String>,
479 health: Vec<String>,
480 dupes: Vec<String>,
481}
482
483fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
484 let Some(base) = base else {
485 return (0, 0);
486 };
487 keys.iter().fold((0, 0), |(introduced, inherited), key| {
488 if base.contains(key) {
489 (introduced, inherited + 1)
490 } else {
491 (introduced + 1, inherited)
492 }
493 })
494}
495
496fn sorted_keys(keys: &FxHashSet<String>) -> Vec<String> {
497 let mut keys: Vec<String> = keys.iter().cloned().collect();
498 keys.sort_unstable();
499 keys
500}
501
502fn snapshot_from_cached(cached: CachedAuditKeySnapshot) -> AuditKeySnapshot {
503 AuditKeySnapshot {
504 dead_code: cached.dead_code.into_iter().collect(),
505 health: cached.health.into_iter().collect(),
506 dupes: cached.dupes.into_iter().collect(),
507 }
508}
509
510fn cached_from_snapshot(
511 key: &AuditBaseSnapshotCacheKey,
512 snapshot: &AuditKeySnapshot,
513) -> CachedAuditKeySnapshot {
514 CachedAuditKeySnapshot {
515 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
516 cli_version: env!("CARGO_PKG_VERSION").to_string(),
517 key_hash: key.hash,
518 base_sha: key.base_sha.clone(),
519 dead_code: sorted_keys(&snapshot.dead_code),
520 health: sorted_keys(&snapshot.health),
521 dupes: sorted_keys(&snapshot.dupes),
522 }
523}
524
525fn audit_base_snapshot_cache_dir(cache_dir: &Path) -> PathBuf {
526 cache_dir
527 .join("cache")
528 .join(format!("audit-base-v{AUDIT_BASE_SNAPSHOT_CACHE_VERSION}"))
529}
530
531fn audit_base_snapshot_cache_file(cache_dir: &Path, key: &AuditBaseSnapshotCacheKey) -> PathBuf {
532 audit_base_snapshot_cache_dir(cache_dir).join(format!("{:016x}.bin", key.hash))
533}
534
535fn ensure_audit_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
536 std::fs::create_dir_all(dir)?;
537 let gitignore = dir.join(".gitignore");
538 if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
539 std::fs::write(gitignore, "*\n")?;
540 }
541 Ok(())
542}
543
544fn load_cached_base_snapshot(
545 opts: &AuditOptions<'_>,
546 key: &AuditBaseSnapshotCacheKey,
547) -> Option<AuditKeySnapshot> {
548 let path = audit_base_snapshot_cache_file(opts.cache_dir, key);
549 let data = std::fs::read(path).ok()?;
550 if data.len() > MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE {
551 return None;
552 }
553 let cached: CachedAuditKeySnapshot = bitcode::decode(&data).ok()?;
554 if cached.version != AUDIT_BASE_SNAPSHOT_CACHE_VERSION
555 || cached.cli_version != env!("CARGO_PKG_VERSION")
556 || cached.key_hash != key.hash
557 || cached.base_sha != key.base_sha
558 {
559 return None;
560 }
561 Some(snapshot_from_cached(cached))
562}
563
564fn save_cached_base_snapshot(
565 opts: &AuditOptions<'_>,
566 key: &AuditBaseSnapshotCacheKey,
567 snapshot: &AuditKeySnapshot,
568) {
569 let dir = audit_base_snapshot_cache_dir(opts.cache_dir);
570 if ensure_audit_base_snapshot_cache_dir(&dir).is_err() {
571 return;
572 }
573 let data = bitcode::encode(&cached_from_snapshot(key, snapshot));
574 let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
575 return;
576 };
577 if tmp.write_all(&data).is_err() {
578 return;
579 }
580 let _ = tmp.persist(audit_base_snapshot_cache_file(opts.cache_dir, key));
581}
582
583fn ambient_git_env_hint() -> Option<String> {
588 use fallow_core::git_env::AMBIENT_GIT_ENV_VARS;
589 for var in AMBIENT_GIT_ENV_VARS {
590 if let Ok(value) = std::env::var(var)
591 && !value.is_empty()
592 {
593 return Some(format!(
594 "{var}={value} is set in the environment; if fallow is being \
595invoked from a git hook this can interfere with worktree operations. Re-run \
596with `env -u {var} fallow audit` to confirm."
597 ));
598 }
599 }
600 None
601}
602
603fn normalized_changed_files(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
604 let git_root = git_toplevel(root);
605 let mut files: Vec<String> = changed_files
606 .iter()
607 .map(|path| {
608 git_root
609 .as_ref()
610 .and_then(|root| path.strip_prefix(root).ok())
611 .unwrap_or(path)
612 .to_string_lossy()
613 .replace('\\', "/")
614 })
615 .collect();
616 files.sort_unstable();
617 files
618}
619
620fn config_file_fingerprint(opts: &AuditOptions<'_>) -> Result<serde_json::Value, ExitCode> {
621 let loaded = if let Some(path) = opts.config_path {
622 let config = fallow_config::FallowConfig::load(path).map_err(|e| {
623 emit_error(
624 &format!("failed to load config '{}': {e}", path.display()),
625 2,
626 opts.output,
627 )
628 })?;
629 Some((config, path.clone()))
630 } else {
631 fallow_config::FallowConfig::find_and_load(opts.root)
632 .map_err(|e| emit_error(&e, 2, opts.output))?
633 };
634
635 let Some((config, path)) = loaded else {
636 return Ok(serde_json::json!({
637 "path": null,
638 "resolved_hash": null,
639 }));
640 };
641 let bytes = serde_json::to_vec(&config).map_err(|e| {
642 emit_error(
643 &format!("failed to serialize resolved config for audit cache key: {e}"),
644 2,
645 opts.output,
646 )
647 })?;
648 Ok(serde_json::json!({
649 "path": path.to_string_lossy(),
650 "resolved_hash": format!("{:016x}", xxh3_64(&bytes)),
651 }))
652}
653
654fn coverage_file_fingerprint(path: &Path, project_root: &Path) -> serde_json::Value {
655 let resolved = crate::health::scoring::resolve_relative_to_root(path, Some(project_root));
656 let file_path = if resolved.is_dir() {
657 resolved.join("coverage-final.json")
658 } else {
659 resolved
660 };
661 match std::fs::read(&file_path) {
662 Ok(bytes) => serde_json::json!({
663 "path": path.to_string_lossy(),
664 "resolved_path": file_path.to_string_lossy(),
665 "content_hash": format!("{:016x}", xxh3_64(&bytes)),
666 "len": bytes.len(),
667 }),
668 Err(err) => serde_json::json!({
669 "path": path.to_string_lossy(),
670 "resolved_path": file_path.to_string_lossy(),
671 "error": err.kind().to_string(),
672 }),
673 }
674}
675
676fn audit_base_snapshot_cache_key(
677 opts: &AuditOptions<'_>,
678 base_ref: &str,
679 changed_files: &FxHashSet<PathBuf>,
680) -> Result<Option<AuditBaseSnapshotCacheKey>, ExitCode> {
681 if opts.no_cache {
682 return Ok(None);
683 }
684 let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
685 return Ok(None);
686 };
687 let config_file = config_file_fingerprint(opts)?;
688 let coverage_file = opts
689 .coverage
690 .map(|p| coverage_file_fingerprint(p, opts.root));
691 let payload = serde_json::json!({
692 "cache_version": AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
693 "cli_version": env!("CARGO_PKG_VERSION"),
694 "base_sha": base_sha,
695 "config_file": config_file,
696 "changed_files": normalized_changed_files(opts.root, changed_files),
697 "production": opts.production,
698 "production_dead_code": opts.production_dead_code,
699 "production_health": opts.production_health,
700 "production_dupes": opts.production_dupes,
701 "workspace": opts.workspace,
702 "changed_workspaces": opts.changed_workspaces,
703 "group_by": opts.group_by.map(|g| format!("{g:?}")),
704 "include_entry_exports": opts.include_entry_exports,
705 "max_crap": opts.max_crap,
706 "coverage": coverage_file,
707 "coverage_root": opts.coverage_root.map(|p| p.to_string_lossy().to_string()),
708 "dead_code_baseline": opts.dead_code_baseline.map(|p| p.to_string_lossy().to_string()),
709 "health_baseline": opts.health_baseline.map(|p| p.to_string_lossy().to_string()),
710 "dupes_baseline": opts.dupes_baseline.map(|p| p.to_string_lossy().to_string()),
711 });
712 let bytes = serde_json::to_vec(&payload).map_err(|e| {
713 emit_error(
714 &format!("failed to build audit cache key: {e}"),
715 2,
716 opts.output,
717 )
718 })?;
719 Ok(Some(AuditBaseSnapshotCacheKey {
720 hash: xxh3_64(&bytes),
721 base_sha,
722 }))
723}
724
725fn compute_base_snapshot(
726 opts: &AuditOptions<'_>,
727 base_ref: &str,
728 changed_files: &FxHashSet<PathBuf>,
729 base_sha: Option<&str>,
730) -> Result<AuditKeySnapshot, ExitCode> {
731 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
732 use std::fmt::Write as _;
733 let mut message =
734 format!("could not create a temporary worktree for base ref '{base_ref}'");
735 if let Some(hint) = ambient_git_env_hint() {
736 let _ = write!(message, "\n hint: {hint}");
737 }
738 return Err(emit_error(&message, 2, opts.output));
739 };
740 let base_root = base_analysis_root(opts.root, worktree.path());
741 let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
742 let current_config_path = opts
743 .config_path
744 .clone()
745 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
746 let base_opts = AuditOptions {
747 root: &base_root,
748 config_path: ¤t_config_path,
749 cache_dir: &base_cache_dir,
750 output: opts.output,
751 no_cache: opts.no_cache,
752 threads: opts.threads,
753 quiet: true,
754 changed_since: None,
755 production: opts.production,
756 production_dead_code: opts.production_dead_code,
757 production_health: opts.production_health,
758 production_dupes: opts.production_dupes,
759 workspace: opts.workspace,
760 changed_workspaces: None,
761 explain: false,
762 explain_skipped: false,
763 performance: false,
764 group_by: opts.group_by,
765 dead_code_baseline: None,
766 health_baseline: None,
767 dupes_baseline: None,
768 max_crap: opts.max_crap,
769 coverage: opts.coverage,
770 coverage_root: opts.coverage_root,
771 gate: AuditGate::All,
772 include_entry_exports: opts.include_entry_exports,
773 runtime_coverage: None,
774 min_invocations_hot: opts.min_invocations_hot,
775 };
776
777 let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
778 let check_production = opts.production_dead_code.unwrap_or(opts.production);
779 let health_production = opts.production_health.unwrap_or(opts.production);
780 let share_dead_code_parse_with_health = check_production == health_production;
781
782 let (check_res, dupes_res) = rayon::join(
783 || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
784 || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
785 );
786 let mut check = check_res?;
787 let dupes = dupes_res?;
788 let shared_parse = if share_dead_code_parse_with_health {
789 check.as_mut().and_then(|r| r.shared_parse.take())
790 } else {
791 None
792 };
793 let health = run_audit_health(&base_opts, None, shared_parse)?;
794 if let Some(ref mut check) = check {
795 check.shared_parse = None;
796 }
797
798 Ok(AuditKeySnapshot {
799 dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
800 dead_code_keys(&r.results, &r.config.root)
801 }),
802 health: health.as_ref().map_or_else(FxHashSet::default, |r| {
803 health_keys(&r.report, &r.config.root)
804 }),
805 dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
806 dupes_keys(&r.report, &r.config.root)
807 }),
808 })
809}
810
811fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
812 let Some(git_root) = git_toplevel(current_root) else {
813 return base_worktree_root.to_path_buf();
814 };
815 let current_root =
816 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
817 match current_root.strip_prefix(&git_root) {
818 Ok(relative) => base_worktree_root.join(relative),
819 Err(err) => {
820 tracing::warn!(
821 current_root = %current_root.display(),
822 git_root = %git_root.display(),
823 error = %err,
824 "Could not remap audit base root into the base worktree; falling back to worktree root"
825 );
826 base_worktree_root.to_path_buf()
827 }
828 }
829}
830
831fn current_keys_as_base_keys(
832 check: Option<&CheckResult>,
833 dupes: Option<&DupesResult>,
834 health: Option<&HealthResult>,
835) -> AuditKeySnapshot {
836 AuditKeySnapshot {
837 dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
838 dead_code_keys(&r.results, &r.config.root)
839 }),
840 health: health.as_ref().map_or_else(FxHashSet::default, |r| {
841 health_keys(&r.report, &r.config.root)
842 }),
843 dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
844 dupes_keys(&r.report, &r.config.root)
845 }),
846 }
847}
848
849fn can_reuse_current_as_base(
850 opts: &AuditOptions<'_>,
851 base_ref: &str,
852 changed_files: &FxHashSet<PathBuf>,
853) -> bool {
854 let Some(git_root) = git_toplevel(opts.root) else {
855 return false;
856 };
857 let cache_dir = opts.cache_dir.to_path_buf();
858 let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
859 let mut reader: Option<BaseFileReader> = None;
862 for path in changed_files {
863 if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
864 continue;
865 }
866 if !is_analysis_input(path) {
867 if is_non_behavioral_doc(path) {
868 continue;
869 }
870 return false;
871 }
872 let Ok(current) = std::fs::read_to_string(path) else {
873 return false;
874 };
875 let Ok(relative) = path.strip_prefix(&git_root) else {
876 return false;
877 };
878 let reader = match reader.as_mut() {
879 Some(reader) => reader,
880 None => {
881 let Some(spawned) = BaseFileReader::spawn(opts.root) else {
882 return false;
883 };
884 reader.insert(spawned)
885 }
886 };
887 let Some(base) = reader.read(base_ref, relative) else {
888 return false;
889 };
890 if current == base {
891 continue;
892 }
893 if !js_ts_tokens_equivalent(path, ¤t, &base) {
894 return false;
895 }
896 }
897 true
898}
899
900struct BaseFileReader {
913 child: Option<crate::signal::ScopedChild>,
917 stdin: Option<std::process::ChildStdin>,
920 stdout: std::io::BufReader<std::process::ChildStdout>,
921}
922
923impl BaseFileReader {
924 fn spawn(root: &Path) -> Option<Self> {
930 let mut command = Command::new("git");
931 command
932 .args(["cat-file", "--batch"])
933 .current_dir(root)
934 .stdin(std::process::Stdio::piped())
935 .stdout(std::process::Stdio::piped())
936 .stderr(std::process::Stdio::null());
937 clear_ambient_git_env(&mut command);
938 let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
939 let stdin = child.take_stdin()?;
940 let stdout = child.take_stdout()?;
941 Some(Self {
942 child: Some(child),
943 stdin: Some(stdin),
944 stdout: std::io::BufReader::new(stdout),
945 })
946 }
947
948 fn read(&mut self, base_ref: &str, relative: &Path) -> Option<String> {
955 use std::io::{BufRead, Read};
956
957 let relative = relative.to_string_lossy().replace('\\', "/");
958 if relative.contains('\n') {
961 return None;
962 }
963
964 let stdin = self.stdin.as_mut()?;
965 writeln!(stdin, "{base_ref}:{relative}").ok()?;
966 stdin.flush().ok()?;
967
968 let mut header = String::new();
969 if self.stdout.read_line(&mut header).ok()? == 0 {
970 return None;
971 }
972 if header.trim_end().ends_with(" missing") {
974 return None;
975 }
976 let size: usize = header.trim_end().rsplit(' ').next()?.parse().ok()?;
978 let mut buf = vec![0u8; size];
979 self.stdout.read_exact(&mut buf).ok()?;
980 let mut newline = [0u8; 1];
983 self.stdout.read_exact(&mut newline).ok()?;
984
985 Some(String::from_utf8_lossy(&buf).into_owned())
986 }
987}
988
989impl Drop for BaseFileReader {
990 fn drop(&mut self) {
991 self.stdin.take();
996 if let Some(child) = self.child.take() {
997 let _ = child.wait();
998 }
999 }
1000}
1001
1002fn is_fallow_cache_artifact(
1003 path: &Path,
1004 cache_dir: &Path,
1005 canonical_cache_dir: Option<&Path>,
1006) -> bool {
1007 path.starts_with(cache_dir)
1008 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
1009}
1010
1011fn remap_cache_dir_for_base_worktree(
1012 current_root: &Path,
1013 base_worktree_root: &Path,
1014 cache_dir: &Path,
1015) -> PathBuf {
1016 if cache_dir.is_absolute()
1017 && let Ok(relative) = cache_dir.strip_prefix(current_root)
1018 {
1019 return base_worktree_root.join(relative);
1020 }
1021 cache_dir.to_path_buf()
1022}
1023
1024fn is_analysis_input(path: &Path) -> bool {
1025 matches!(
1026 path.extension().and_then(|ext| ext.to_str()),
1027 Some(
1028 "js" | "jsx"
1029 | "ts"
1030 | "tsx"
1031 | "mjs"
1032 | "mts"
1033 | "cjs"
1034 | "cts"
1035 | "vue"
1036 | "svelte"
1037 | "astro"
1038 | "mdx"
1039 | "css"
1040 | "scss"
1041 )
1042 )
1043}
1044
1045fn is_non_behavioral_doc(path: &Path) -> bool {
1046 matches!(
1047 path.extension().and_then(|ext| ext.to_str()),
1048 Some("md" | "markdown" | "txt" | "rst" | "adoc")
1049 )
1050}
1051
1052fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
1053 if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
1054 return false;
1055 }
1056 if !matches!(
1057 path.extension().and_then(|ext| ext.to_str()),
1058 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
1059 ) {
1060 return false;
1061 }
1062 let current_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, current, false);
1063 let base_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, base, false);
1064 current_tokens
1065 .tokens
1066 .iter()
1067 .map(|token| &token.kind)
1068 .eq(base_tokens.tokens.iter().map(|token| &token.kind))
1069}
1070
1071fn remap_focus_files(
1072 files: &FxHashSet<PathBuf>,
1073 from_root: &Path,
1074 to_root: &Path,
1075) -> Option<FxHashSet<PathBuf>> {
1076 let mut remapped = FxHashSet::default();
1077 for file in files {
1078 if let Ok(relative) = file.strip_prefix(from_root) {
1079 remapped.insert(to_root.join(relative));
1080 }
1081 }
1082 if remapped.is_empty() {
1083 return None;
1084 }
1085 Some(remapped)
1086}
1087
1088#[cfg(test)]
1089use std::time::SystemTime;
1090
1091#[cfg(test)]
1092use crate::base_worktree::{
1093 ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
1094 is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
1095 materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
1096 remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
1097 sweep_orphan_audit_worktrees, touch_last_used,
1098};
1099
1100#[path = "audit_keys.rs"]
1101mod keys;
1102
1103use keys::{
1104 dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
1105 retain_introduced_dead_code,
1106};
1107
1108struct HeadAnalyses {
1109 check: Option<CheckResult>,
1110 dupes: Option<DupesResult>,
1111 health: Option<HealthResult>,
1112}
1113
1114fn run_audit_head_analyses(
1121 opts: &AuditOptions<'_>,
1122 changed_since: Option<&str>,
1123 changed_files: &FxHashSet<PathBuf>,
1124) -> Result<HeadAnalyses, ExitCode> {
1125 let check_production = opts.production_dead_code.unwrap_or(opts.production);
1126 let health_production = opts.production_health.unwrap_or(opts.production);
1127 let dupes_production = opts.production_dupes.unwrap_or(opts.production);
1128 let share_dead_code_parse_with_health = check_production == health_production;
1129 let share_dead_code_files_with_dupes =
1130 share_dead_code_parse_with_health && check_production == dupes_production;
1131
1132 let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
1133 let dupes_files = if share_dead_code_files_with_dupes {
1134 check
1135 .as_ref()
1136 .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
1137 } else {
1138 None
1139 };
1140 let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
1141 let shared_parse = if share_dead_code_parse_with_health {
1142 check.as_mut().and_then(|r| r.shared_parse.take())
1143 } else {
1144 None
1145 };
1146 let health = run_audit_health(opts, changed_since, shared_parse)?;
1147 Ok(HeadAnalyses {
1148 check,
1149 dupes,
1150 health,
1151 })
1152}
1153
1154pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1156 let start = Instant::now();
1157
1158 let (base_ref, base_description) = resolve_base_ref(opts)?;
1159
1160 sweep_old_reusable_caches(
1164 opts.root,
1165 resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1166 opts.quiet,
1167 );
1168
1169 let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1170 return Err(emit_error(
1171 &format!(
1172 "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1173 ),
1174 2,
1175 opts.output,
1176 ));
1177 };
1178 let changed_files_count = changed_files.len();
1179
1180 if changed_files.is_empty() {
1181 return Ok(empty_audit_result(
1182 base_ref,
1183 base_description,
1184 opts,
1185 start.elapsed(),
1186 ));
1187 }
1188
1189 let changed_since = Some(base_ref.as_str());
1190
1191 let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1192 && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1193 let base_cache_key = if needs_real_base_snapshot {
1194 audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1195 } else {
1196 None
1197 };
1198 let cached_base_snapshot = base_cache_key
1199 .as_ref()
1200 .and_then(|key| load_cached_base_snapshot(opts, key));
1201
1202 let (head_res, base_res) = if needs_real_base_snapshot && cached_base_snapshot.is_none() {
1203 let base_sha = base_cache_key.as_ref().map(|key| key.base_sha.as_str());
1204 let (h, b) = rayon::join(
1205 || run_audit_head_analyses(opts, changed_since, &changed_files),
1206 || compute_base_snapshot(opts, &base_ref, &changed_files, base_sha),
1207 );
1208 (h, Some(b))
1209 } else {
1210 (
1211 run_audit_head_analyses(opts, changed_since, &changed_files),
1212 None,
1213 )
1214 };
1215
1216 let head = head_res?;
1217 let mut check_result = head.check;
1218 let dupes_result = head.dupes;
1219 let health_result = head.health;
1220
1221 let (base_snapshot, base_snapshot_skipped) = if matches!(opts.gate, AuditGate::NewOnly) {
1222 if let Some(snapshot) = cached_base_snapshot {
1223 (Some(snapshot), false)
1224 } else if let Some(base_res) = base_res {
1225 let snapshot = base_res?;
1226 if let Some(ref key) = base_cache_key {
1227 save_cached_base_snapshot(opts, key, &snapshot);
1228 }
1229 (Some(snapshot), false)
1230 } else {
1231 (
1232 Some(current_keys_as_base_keys(
1233 check_result.as_ref(),
1234 dupes_result.as_ref(),
1235 health_result.as_ref(),
1236 )),
1237 true,
1238 )
1239 }
1240 } else {
1241 (None, false)
1242 };
1243 if let Some(ref mut check) = check_result {
1244 check.shared_parse = None;
1245 }
1246 let attribution = compute_audit_attribution(
1247 check_result.as_ref(),
1248 dupes_result.as_ref(),
1249 health_result.as_ref(),
1250 base_snapshot.as_ref(),
1251 opts.gate,
1252 );
1253 let verdict = if matches!(opts.gate, AuditGate::NewOnly) {
1254 compute_introduced_verdict(
1255 check_result.as_ref(),
1256 dupes_result.as_ref(),
1257 health_result.as_ref(),
1258 base_snapshot.as_ref(),
1259 )
1260 } else {
1261 compute_verdict(
1262 check_result.as_ref(),
1263 dupes_result.as_ref(),
1264 health_result.as_ref(),
1265 )
1266 };
1267 let summary = build_summary(
1268 check_result.as_ref(),
1269 dupes_result.as_ref(),
1270 health_result.as_ref(),
1271 );
1272 crate::telemetry::note_final_result_count(
1273 summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1274 );
1275
1276 Ok(AuditResult {
1277 verdict,
1278 summary,
1279 attribution,
1280 base_snapshot,
1281 base_snapshot_skipped,
1282 changed_files_count,
1283 changed_files: changed_files.into_iter().collect(),
1284 base_ref,
1285 base_description,
1286 head_sha: get_head_sha(opts.root),
1287 output: opts.output,
1288 performance: opts.performance,
1289 check: check_result,
1290 dupes: dupes_result,
1291 health: health_result,
1292 elapsed: start.elapsed(),
1293 })
1294}
1295
1296fn parse_audit_base_override(raw: Option<String>) -> Option<String> {
1299 let trimmed = raw?.trim().to_string();
1300 if trimmed.is_empty() {
1301 None
1302 } else {
1303 Some(trimmed)
1304 }
1305}
1306
1307fn audit_base_env_override() -> Option<String> {
1311 parse_audit_base_override(std::env::var("FALLOW_AUDIT_BASE").ok())
1312}
1313
1314fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<(String, Option<String>), ExitCode> {
1318 if let Some(ref_str) = opts.changed_since {
1319 return Ok((ref_str.to_string(), None));
1320 }
1321 if let Some(env_ref) = audit_base_env_override() {
1322 if let Err(e) = crate::validate::validate_git_ref(&env_ref) {
1323 return Err(emit_error(
1324 &format!("FALLOW_AUDIT_BASE='{env_ref}' is not a valid git ref: {e}"),
1325 2,
1326 opts.output,
1327 ));
1328 }
1329 let description = format!("FALLOW_AUDIT_BASE={env_ref}");
1330 return Ok((env_ref, Some(description)));
1331 }
1332 let Some(detected) = auto_detect_base_ref(opts.root) else {
1333 return Err(emit_error(
1334 "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
1335 2,
1336 opts.output,
1337 ));
1338 };
1339 if let Err(e) = crate::validate::validate_git_ref(&detected.git_ref) {
1340 return Err(emit_error(
1341 &format!(
1342 "auto-detected base ref '{}' is not a valid git ref: {e}",
1343 detected.git_ref
1344 ),
1345 2,
1346 opts.output,
1347 ));
1348 }
1349 Ok((detected.git_ref, Some(detected.description)))
1350}
1351
1352fn empty_audit_result(
1354 base_ref: String,
1355 base_description: Option<String>,
1356 opts: &AuditOptions<'_>,
1357 elapsed: Duration,
1358) -> AuditResult {
1359 crate::telemetry::note_final_result_count(0);
1360
1361 AuditResult {
1362 verdict: AuditVerdict::Pass,
1363 summary: AuditSummary {
1364 dead_code_issues: 0,
1365 dead_code_has_errors: false,
1366 complexity_findings: 0,
1367 max_cyclomatic: None,
1368 duplication_clone_groups: 0,
1369 },
1370 attribution: AuditAttribution {
1371 gate: opts.gate,
1372 ..AuditAttribution::default()
1373 },
1374 base_snapshot: None,
1375 base_snapshot_skipped: false,
1376 changed_files_count: 0,
1377 changed_files: Vec::new(),
1378 base_ref,
1379 base_description,
1380 head_sha: get_head_sha(opts.root),
1381 output: opts.output,
1382 performance: opts.performance,
1383 check: None,
1384 dupes: None,
1385 health: None,
1386 elapsed,
1387 }
1388}
1389
1390fn run_audit_check<'a>(
1392 opts: &'a AuditOptions<'a>,
1393 changed_since: Option<&'a str>,
1394 retain_modules_for_health: bool,
1395) -> Result<Option<CheckResult>, ExitCode> {
1396 let filters = IssueFilters::default();
1397 let trace_opts = TraceOptions {
1398 trace_export: None,
1399 trace_file: None,
1400 trace_dependency: None,
1401 performance: opts.performance,
1402 };
1403 match crate::check::execute_check(&CheckOptions {
1404 root: opts.root,
1405 config_path: opts.config_path,
1406 output: opts.output,
1407 no_cache: opts.no_cache,
1408 threads: opts.threads,
1409 quiet: opts.quiet,
1410 fail_on_issues: false,
1411 filters: &filters,
1412 changed_since,
1413 diff_index: None,
1414 use_shared_diff_index: true,
1415 baseline: opts.dead_code_baseline,
1416 save_baseline: None,
1417 sarif_file: None,
1418 production: opts.production_dead_code.unwrap_or(opts.production),
1419 production_override: opts.production_dead_code,
1420 workspace: opts.workspace,
1421 changed_workspaces: opts.changed_workspaces,
1422 group_by: opts.group_by,
1423 include_dupes: false,
1424 trace_opts: &trace_opts,
1425 explain: opts.explain,
1426 top: None,
1427 file: &[],
1428 include_entry_exports: opts.include_entry_exports,
1429 summary: false,
1430 regression_opts: crate::regression::RegressionOpts {
1431 fail_on_regression: false,
1432 tolerance: crate::regression::Tolerance::Absolute(0),
1433 regression_baseline_file: None,
1434 save_target: crate::regression::SaveRegressionTarget::None,
1435 scoped: true,
1436 quiet: opts.quiet,
1437 output: opts.output,
1438 },
1439 retain_modules_for_health,
1440 defer_performance: false,
1441 }) {
1442 Ok(r) => Ok(Some(r)),
1443 Err(code) => Err(code),
1444 }
1445}
1446
1447fn run_audit_dupes<'a>(
1453 opts: &'a AuditOptions<'a>,
1454 changed_since: Option<&'a str>,
1455 changed_files: Option<&'a FxHashSet<PathBuf>>,
1456 pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1457) -> Result<Option<DupesResult>, ExitCode> {
1458 let dupes_cfg = match crate::load_config_for_analysis(
1459 opts.root,
1460 opts.config_path,
1461 opts.output,
1462 opts.no_cache,
1463 opts.threads,
1464 opts.production_dupes
1465 .or_else(|| opts.production.then_some(true)),
1466 opts.quiet,
1467 fallow_config::ProductionAnalysis::Dupes,
1468 ) {
1469 Ok(c) => c.duplicates,
1470 Err(code) => return Err(code),
1471 };
1472 let dupes_opts = DupesOptions {
1473 root: opts.root,
1474 config_path: opts.config_path,
1475 output: opts.output,
1476 no_cache: opts.no_cache,
1477 threads: opts.threads,
1478 quiet: opts.quiet,
1479 mode: Some(DupesMode::from(dupes_cfg.mode)),
1480 min_tokens: Some(dupes_cfg.min_tokens),
1481 min_lines: Some(dupes_cfg.min_lines),
1482 min_occurrences: Some(dupes_cfg.min_occurrences),
1483 threshold: Some(dupes_cfg.threshold),
1484 skip_local: dupes_cfg.skip_local,
1485 cross_language: dupes_cfg.cross_language,
1486 ignore_imports: dupes_cfg.ignore_imports,
1487 top: None,
1488 baseline_path: opts.dupes_baseline,
1489 save_baseline_path: None,
1490 production: opts.production_dupes.unwrap_or(opts.production),
1491 production_override: opts.production_dupes,
1492 trace: None,
1493 changed_since,
1494 diff_index: None,
1495 use_shared_diff_index: true,
1496 changed_files,
1497 workspace: opts.workspace,
1498 changed_workspaces: opts.changed_workspaces,
1499 explain: opts.explain,
1500 explain_skipped: opts.explain_skipped,
1501 summary: false,
1502 group_by: opts.group_by,
1503 performance: false,
1504 };
1505 let dupes_run = if let Some(files) = pre_discovered {
1506 crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1507 } else {
1508 crate::dupes::execute_dupes(&dupes_opts)
1509 };
1510 match dupes_run {
1511 Ok(r) => Ok(Some(r)),
1512 Err(code) => Err(code),
1513 }
1514}
1515
1516fn run_audit_health<'a>(
1518 opts: &'a AuditOptions<'a>,
1519 changed_since: Option<&'a str>,
1520 shared_parse: Option<crate::health::SharedParseData>,
1521) -> Result<Option<HealthResult>, ExitCode> {
1522 let runtime_coverage = match opts.runtime_coverage {
1523 Some(path) => match crate::health::coverage::prepare_options(
1524 path,
1525 opts.min_invocations_hot,
1526 None,
1527 None,
1528 opts.output,
1529 ) {
1530 Ok(options) => Some(options),
1531 Err(code) => return Err(code),
1532 },
1533 None => None,
1534 };
1535
1536 let health_opts = HealthOptions {
1537 root: opts.root,
1538 config_path: opts.config_path,
1539 output: opts.output,
1540 no_cache: opts.no_cache,
1541 threads: opts.threads,
1542 quiet: opts.quiet,
1543 max_cyclomatic: None,
1544 max_cognitive: None,
1545 max_crap: opts.max_crap,
1546 top: None,
1547 sort: SortBy::Cyclomatic,
1548 production: opts.production_health.unwrap_or(opts.production),
1549 production_override: opts.production_health,
1550 changed_since,
1551 diff_index: None,
1552 use_shared_diff_index: true,
1553 workspace: opts.workspace,
1554 changed_workspaces: opts.changed_workspaces,
1555 baseline: opts.health_baseline,
1556 save_baseline: None,
1557 complexity: true,
1558 complexity_breakdown: false,
1559 file_scores: false,
1560 coverage_gaps: false,
1561 config_activates_coverage_gaps: false,
1562 hotspots: false,
1563 ownership: false,
1564 ownership_emails: None,
1565 targets: false,
1566 force_full: false,
1567 score_only_output: false,
1568 enforce_coverage_gap_gate: false,
1569 effort: None,
1570 score: false,
1571 min_score: None,
1572 since: None,
1573 min_commits: None,
1574 explain: opts.explain,
1575 summary: false,
1576 save_snapshot: None,
1577 trend: false,
1578 group_by: opts.group_by,
1579 coverage: opts.coverage,
1580 coverage_root: opts.coverage_root,
1581 performance: opts.performance,
1582 min_severity: None,
1583 report_only: false,
1584 runtime_coverage,
1585 churn_file: None,
1587 };
1588 let health_run = if let Some(shared) = shared_parse {
1589 crate::health::execute_health_with_shared_parse(&health_opts, shared)
1590 } else {
1591 crate::health::execute_health(&health_opts)
1592 };
1593 match health_run {
1594 Ok(r) => Ok(Some(r)),
1595 Err(code) => Err(code),
1596 }
1597}
1598
1599#[path = "audit_output.rs"]
1600mod output;
1601
1602pub use output::print_audit_result;
1603
1604pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
1610 if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
1611 return emit_error(&e, 2, opts.output);
1612 }
1613 let coverage_resolved = opts
1614 .coverage
1615 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1616 let runtime_coverage_resolved = opts
1617 .runtime_coverage
1618 .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1619 let resolved_opts = AuditOptions {
1620 coverage: coverage_resolved.as_deref(),
1621 runtime_coverage: runtime_coverage_resolved.as_deref(),
1622 ..*opts
1623 };
1624 match execute_audit(&resolved_opts) {
1625 Ok(result) => {
1626 let mut findings = result
1627 .check
1628 .as_ref()
1629 .map(|c| crate::impact::collect_dead_code_findings(&c.results))
1630 .unwrap_or_default();
1631 if let Some(health) = result.health.as_ref() {
1632 findings.extend(crate::impact::collect_complexity_findings(&health.report));
1633 }
1634 let clones = result
1635 .dupes
1636 .as_ref()
1637 .map(|d| crate::impact::collect_clone_findings(&d.report))
1638 .unwrap_or_default();
1639 let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
1640 let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
1641 c.results.active_suppressions.as_slice()
1642 });
1643 let attribution = crate::impact::AttributionInput {
1644 root: opts.root,
1645 scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
1646 findings,
1647 clones,
1648 suppressions,
1649 };
1650 crate::impact::record_audit_run(
1651 opts.root,
1652 &result.summary,
1653 &crate::impact::AuditRunRecord {
1654 verdict: result.verdict,
1655 gate: gate_marker.is_some(),
1656 git_sha: result.head_sha.as_deref(),
1657 version: env!("CARGO_PKG_VERSION"),
1658 timestamp: &crate::vital_signs::chrono_timestamp(),
1659 attribution: Some(&attribution),
1660 },
1661 );
1662 print_audit_result(&result, opts.quiet, opts.explain)
1663 }
1664 Err(code) => code,
1665 }
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670 use super::*;
1671 use std::{fs, process::Command};
1672
1673 fn git(dir: &std::path::Path, args: &[&str]) {
1674 let output = Command::new("git")
1675 .args(args)
1676 .current_dir(dir)
1677 .env_remove("GIT_DIR")
1678 .env_remove("GIT_WORK_TREE")
1679 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1680 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1681 .env("GIT_AUTHOR_NAME", "test")
1682 .env("GIT_AUTHOR_EMAIL", "test@test.com")
1683 .env("GIT_COMMITTER_NAME", "test")
1684 .env("GIT_COMMITTER_EMAIL", "test@test.com")
1685 .output()
1686 .expect("git command failed");
1687 assert!(
1688 output.status.success(),
1689 "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
1690 args,
1691 String::from_utf8_lossy(&output.stdout),
1692 String::from_utf8_lossy(&output.stderr)
1693 );
1694 }
1695
1696 #[test]
1697 fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
1698 let temp = std::env::temp_dir();
1699 let audit_path = temp.join("fallow-audit-base-123-456");
1700 let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
1701 let canonical_audit_path = temp
1702 .canonicalize()
1703 .unwrap_or_else(|_| temp.clone())
1704 .join("fallow-audit-base-456-789");
1705 let unrelated_temp = temp.join("other-worktree");
1706 let output = format!(
1707 "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
1708 audit_path.display(),
1709 unrelated_temp.display(),
1710 reusable_path.display()
1711 );
1712
1713 assert_eq!(
1714 parse_worktree_list(&output),
1715 vec![audit_path, reusable_path.clone()]
1716 );
1717 assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
1718 assert!(is_reusable_audit_worktree_path(&reusable_path));
1719 assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
1720 assert_eq!(
1721 audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
1722 None
1723 );
1724 assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
1725 }
1726
1727 fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
1731 let root = parent.join(name);
1732 fs::create_dir_all(&root).expect("repo root should be created");
1733 fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
1734 git(&root, &["init", "-b", "main"]);
1735 git(&root, &["add", "."]);
1736 git(
1737 &root,
1738 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1739 );
1740 root
1741 }
1742
1743 fn commit_file(repo: &std::path::Path, name: &str, body: &str) -> String {
1745 fs::write(repo.join(name), body).expect("file should be written");
1746 git(repo, &["add", "."]);
1747 git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", name]);
1748 git_rev_parse(repo, "HEAD").expect("HEAD should resolve")
1749 }
1750
1751 #[test]
1752 fn auto_detect_base_ref_resolves_origin_default_to_merge_base() {
1753 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1754 let repo = init_throwaway_repo(tmp.path(), "repo");
1755 let head = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
1756 git(&repo, &["branch", "trunk"]);
1757 git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
1758 git(
1759 &repo,
1760 &[
1761 "symbolic-ref",
1762 "refs/remotes/origin/HEAD",
1763 "refs/remotes/origin/trunk",
1764 ],
1765 );
1766
1767 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1768 assert_eq!(detected.git_ref, head);
1771 assert_eq!(detected.description, "merge-base with origin/trunk");
1772 }
1773
1774 #[test]
1779 fn auto_detect_base_ref_ignores_stale_local_main() {
1780 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1781 let repo = init_throwaway_repo(tmp.path(), "repo");
1782 let stale = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
1783
1784 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1786 git(
1787 &repo,
1788 &[
1789 "symbolic-ref",
1790 "refs/remotes/origin/HEAD",
1791 "refs/remotes/origin/main",
1792 ],
1793 );
1794 let fork_point = commit_file(&repo, "teammate.txt", "merged work\n");
1795 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1796
1797 git(&repo, &["checkout", "-b", "feature", &fork_point]);
1800 commit_file(&repo, "feature.txt", "my change\n");
1801 git(&repo, &["branch", "-f", "main", &stale]);
1802
1803 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1804 assert_eq!(
1805 detected.git_ref, fork_point,
1806 "base must be the fork point (origin/main), not stale local main"
1807 );
1808 assert_ne!(
1809 detected.git_ref, stale,
1810 "must not diff against stale local main"
1811 );
1812 assert_eq!(detected.description, "merge-base with origin/main");
1813 }
1814
1815 #[test]
1816 fn auto_detect_base_ref_prefers_configured_upstream() {
1817 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1818 let repo = init_throwaway_repo(tmp.path(), "repo");
1819 let fork_point = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
1820 git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
1823 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1824
1825 git(&repo, &["checkout", "-b", "feature"]);
1826 git(
1827 &repo,
1828 &["branch", "--set-upstream-to=origin/main", "feature"],
1829 );
1830 commit_file(&repo, "feature.txt", "my change\n");
1831
1832 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1833 assert_eq!(detected.git_ref, fork_point);
1834 assert_eq!(detected.description, "merge-base with origin/main");
1835 }
1836
1837 #[test]
1838 fn auto_detect_base_ref_falls_back_to_local_main_without_remote() {
1839 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1840 let repo = init_throwaway_repo(tmp.path(), "repo");
1841
1842 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1843 assert_eq!(detected.git_ref, "main");
1844 assert_eq!(detected.description, "local main");
1845 }
1846
1847 #[test]
1848 fn auto_detect_base_ref_falls_back_to_local_master_without_remote() {
1849 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1850 let repo = tmp.path().join("repo");
1851 fs::create_dir_all(&repo).expect("repo root should be created");
1852 fs::write(repo.join("README.md"), "seed\n").expect("seed file should be written");
1853 git(&repo, &["init", "-b", "master"]);
1854 git(&repo, &["add", "."]);
1855 git(
1856 &repo,
1857 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1858 );
1859
1860 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1861 assert_eq!(detected.git_ref, "master");
1862 assert_eq!(detected.description, "local master");
1863 }
1864
1865 #[test]
1866 fn auto_detect_base_ref_returns_none_outside_git_repo() {
1867 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1868
1869 assert!(auto_detect_base_ref(tmp.path()).is_none());
1870 }
1871
1872 #[test]
1873 fn parse_audit_base_override_trims_and_rejects_empty() {
1874 assert_eq!(parse_audit_base_override(None), None);
1875 assert_eq!(parse_audit_base_override(Some(String::new())), None);
1876 assert_eq!(parse_audit_base_override(Some(" ".to_string())), None);
1877 assert_eq!(
1878 parse_audit_base_override(Some(" origin/main ".to_string())),
1879 Some("origin/main".to_string())
1880 );
1881 }
1882
1883 #[test]
1887 fn auto_detect_base_ref_falls_back_to_remote_tip_without_common_ancestor() {
1888 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1889 let repo = init_throwaway_repo(tmp.path(), "repo");
1890 git(&repo, &["checkout", "--orphan", "unrelated"]);
1893 commit_file(&repo, "unrelated.txt", "no shared history\n");
1894 let unrelated = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
1895 git(
1896 &repo,
1897 &["update-ref", "refs/remotes/origin/main", &unrelated],
1898 );
1899 git(
1900 &repo,
1901 &[
1902 "symbolic-ref",
1903 "refs/remotes/origin/HEAD",
1904 "refs/remotes/origin/main",
1905 ],
1906 );
1907 git(&repo, &["checkout", "main"]);
1908
1909 let detected = auto_detect_base_ref(&repo).expect("base should be detected");
1910 assert_eq!(detected.git_ref, "origin/main");
1911 assert_eq!(detected.description, "origin/main (tip)");
1912 }
1913
1914 #[test]
1915 fn get_head_sha_returns_short_head_for_git_repo() {
1916 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1917 let repo = init_throwaway_repo(tmp.path(), "repo");
1918 let output = Command::new("git")
1919 .args(["rev-parse", "--short", "HEAD"])
1920 .current_dir(&repo)
1921 .env_remove("GIT_DIR")
1922 .env_remove("GIT_WORK_TREE")
1923 .output()
1924 .expect("git rev-parse should run");
1925 assert!(output.status.success());
1926
1927 assert_eq!(
1928 get_head_sha(&repo),
1929 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1930 );
1931 }
1932
1933 #[test]
1934 fn get_head_sha_returns_none_outside_git_repo() {
1935 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1936
1937 assert_eq!(get_head_sha(tmp.path()), None);
1938 }
1939
1940 fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1941 list_audit_worktrees(repo_root)
1942 .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
1943 }
1944
1945 fn worktree_admin_entry_present(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1953 let basename = worktree_path
1954 .file_name()
1955 .and_then(|n| n.to_str())
1956 .expect("reusable worktree path has a utf-8 basename");
1957 let output = Command::new("git")
1958 .args(["worktree", "list", "--porcelain"])
1959 .current_dir(repo_root)
1960 .env_remove("GIT_DIR")
1961 .env_remove("GIT_WORK_TREE")
1962 .output()
1963 .expect("git worktree list should run");
1964 String::from_utf8_lossy(&output.stdout)
1965 .lines()
1966 .filter_map(|line| line.strip_prefix("worktree "))
1967 .any(|p| p.ends_with(basename))
1968 }
1969
1970 #[test]
1971 fn worktree_cleanup_guard_runs_on_drop() {
1972 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1973 let repo = init_throwaway_repo(tmp.path(), "repo");
1974 let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
1975
1976 git(
1977 &repo,
1978 &[
1979 "worktree",
1980 "add",
1981 "--detach",
1982 "--quiet",
1983 worktree_path.to_str().expect("path is utf-8"),
1984 "HEAD",
1985 ],
1986 );
1987 assert!(worktree_path.is_dir());
1988 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1989
1990 {
1991 let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1992 }
1993
1994 assert!(
1995 !worktree_path.exists(),
1996 "guard Drop should remove the worktree directory",
1997 );
1998 assert!(
1999 !worktree_is_registered_with_git(&repo, &worktree_path),
2000 "guard Drop should remove the git worktree registration",
2001 );
2002 }
2003
2004 #[test]
2005 fn worktree_cleanup_guard_defused_skips_drop() {
2006 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2007 let repo = init_throwaway_repo(tmp.path(), "repo");
2008 let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
2009
2010 git(
2011 &repo,
2012 &[
2013 "worktree",
2014 "add",
2015 "--detach",
2016 "--quiet",
2017 worktree_path.to_str().expect("path is utf-8"),
2018 "HEAD",
2019 ],
2020 );
2021 assert!(worktree_path.is_dir());
2022
2023 {
2024 let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
2025 guard.defuse();
2026 guard.defuse();
2027 }
2028
2029 assert!(
2030 worktree_path.is_dir(),
2031 "defused guard must not remove the worktree on drop",
2032 );
2033 assert!(
2034 worktree_is_registered_with_git(&repo, &worktree_path),
2035 "defused guard must not unregister the worktree from git",
2036 );
2037
2038 remove_audit_worktree(&repo, &worktree_path);
2039 let _ = fs::remove_dir_all(&worktree_path);
2040 }
2041
2042 #[test]
2043 fn audit_orphan_sweep_removes_dead_pid_worktree() {
2044 const DEAD_PID: u32 = 99_999_999;
2045 assert!(!process_is_alive(DEAD_PID));
2046
2047 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2048 let repo = init_throwaway_repo(tmp.path(), "repo");
2049
2050 let worktree_path = std::env::temp_dir().join(format!(
2051 "fallow-audit-base-{}-{}",
2052 DEAD_PID,
2053 std::time::SystemTime::now()
2054 .duration_since(std::time::UNIX_EPOCH)
2055 .expect("clock should be after epoch")
2056 .as_nanos()
2057 ));
2058 git(
2059 &repo,
2060 &[
2061 "worktree",
2062 "add",
2063 "--detach",
2064 "--quiet",
2065 worktree_path.to_str().expect("path is utf-8"),
2066 "HEAD",
2067 ],
2068 );
2069 assert!(worktree_path.is_dir());
2070 assert!(worktree_is_registered_with_git(&repo, &worktree_path));
2071
2072 sweep_orphan_audit_worktrees(&repo);
2073
2074 assert!(
2075 !worktree_path.exists(),
2076 "sweep should remove worktree owned by a dead PID",
2077 );
2078 assert!(
2079 !worktree_is_registered_with_git(&repo, &worktree_path),
2080 "sweep should unregister worktree owned by a dead PID",
2081 );
2082 }
2083
2084 #[test]
2085 fn audit_orphan_sweep_keeps_live_pid_worktree() {
2086 let live_pid = std::process::id();
2087 assert!(process_is_alive(live_pid));
2088
2089 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2090 let repo = init_throwaway_repo(tmp.path(), "repo");
2091
2092 let worktree_path = std::env::temp_dir().join(format!(
2093 "fallow-audit-base-{}-{}",
2094 live_pid,
2095 std::time::SystemTime::now()
2096 .duration_since(std::time::UNIX_EPOCH)
2097 .expect("clock should be after epoch")
2098 .as_nanos()
2099 ));
2100 git(
2101 &repo,
2102 &[
2103 "worktree",
2104 "add",
2105 "--detach",
2106 "--quiet",
2107 worktree_path.to_str().expect("path is utf-8"),
2108 "HEAD",
2109 ],
2110 );
2111
2112 sweep_orphan_audit_worktrees(&repo);
2113
2114 assert!(
2115 worktree_path.is_dir(),
2116 "sweep must not remove worktree owned by a live PID",
2117 );
2118 assert!(
2119 worktree_is_registered_with_git(&repo, &worktree_path),
2120 "sweep must not unregister worktree owned by a live PID",
2121 );
2122
2123 remove_audit_worktree(&repo, &worktree_path);
2124 let _ = fs::remove_dir_all(&worktree_path);
2125 }
2126
2127 fn make_reusable_path(label: &str) -> PathBuf {
2131 let nanos = std::time::SystemTime::now()
2132 .duration_since(std::time::UNIX_EPOCH)
2133 .expect("clock should be after epoch")
2134 .as_nanos();
2135 std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
2136 }
2137
2138 fn register_reusable_worktree(repo: &Path, path: &Path) {
2142 git(
2143 repo,
2144 &[
2145 "worktree",
2146 "add",
2147 "--detach",
2148 "--quiet",
2149 path.to_str().expect("path is utf-8"),
2150 "HEAD",
2151 ],
2152 );
2153 }
2154
2155 fn write_sidecar_with_age(path: &Path, age: Duration) {
2156 let sidecar = reusable_worktree_last_used_path(path);
2157 let file = std::fs::OpenOptions::new()
2158 .create(true)
2159 .truncate(false)
2160 .write(true)
2161 .open(&sidecar)
2162 .expect("sidecar should open");
2163 let when = SystemTime::now()
2164 .checked_sub(age)
2165 .expect("backdated time should fit in SystemTime");
2166 file.set_modified(when)
2167 .expect("set_modified should succeed");
2168 }
2169
2170 fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
2173 remove_audit_worktree(repo, path);
2174 let _ = fs::remove_dir_all(path);
2175 let _ = fs::remove_file(reusable_worktree_last_used_path(path));
2176 let _ = fs::remove_file(reusable_worktree_lock_path(path));
2177 }
2178
2179 #[test]
2180 fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
2181 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2182 let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
2183 let worktree_path = make_reusable_path("gc-remove");
2184 register_reusable_worktree(&repo, &worktree_path);
2185 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2186
2187 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2188
2189 assert!(
2190 !worktree_path.exists(),
2191 "sweep should remove worktree dir whose sidecar is older than the threshold",
2192 );
2193 assert!(
2194 !worktree_is_registered_with_git(&repo, &worktree_path),
2195 "sweep should unregister the worktree from git",
2196 );
2197 assert!(
2198 !reusable_worktree_last_used_path(&worktree_path).exists(),
2199 "sweep should remove the sidecar `.last-used` file alongside the worktree",
2200 );
2201 cleanup_reusable_worktree(&repo, &worktree_path);
2202 }
2203
2204 #[test]
2205 fn reusable_cache_gc_keeps_fresh_entry() {
2206 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2207 let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
2208 let worktree_path = make_reusable_path("gc-keep");
2209 register_reusable_worktree(&repo, &worktree_path);
2210 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
2211
2212 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2213
2214 assert!(
2215 worktree_path.is_dir(),
2216 "sweep must not remove a worktree whose sidecar is fresher than the threshold",
2217 );
2218 assert!(
2219 worktree_is_registered_with_git(&repo, &worktree_path),
2220 "sweep must not unregister a fresh worktree",
2221 );
2222 cleanup_reusable_worktree(&repo, &worktree_path);
2223 }
2224
2225 #[test]
2226 fn reusable_cache_gc_skips_locked_entry() {
2227 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2228 let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
2229 let worktree_path = make_reusable_path("gc-locked");
2230 register_reusable_worktree(&repo, &worktree_path);
2231 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2232
2233 let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
2234 .expect("test should acquire the lock first");
2235
2236 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2237
2238 assert!(
2239 worktree_path.is_dir(),
2240 "sweep must skip a locked entry even when its sidecar is stale",
2241 );
2242 assert!(
2243 worktree_is_registered_with_git(&repo, &worktree_path),
2244 "sweep must not unregister a locked entry",
2245 );
2246 drop(lock);
2247 cleanup_reusable_worktree(&repo, &worktree_path);
2248 }
2249
2250 #[test]
2251 fn reusable_cache_gc_grace_when_sidecar_absent() {
2252 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2253 let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
2254 let worktree_path = make_reusable_path("gc-grace");
2255 register_reusable_worktree(&repo, &worktree_path);
2256 let sidecar = reusable_worktree_last_used_path(&worktree_path);
2257 assert!(
2258 !sidecar.exists(),
2259 "test pre-condition: sidecar should not exist",
2260 );
2261
2262 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2263
2264 assert!(
2265 worktree_path.is_dir(),
2266 "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
2267 );
2268 assert!(
2269 sidecar.exists(),
2270 "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
2271 );
2272 let mtime = std::fs::metadata(&sidecar)
2273 .and_then(|m| m.modified())
2274 .expect("seeded sidecar should have a readable mtime");
2275 let age = SystemTime::now()
2276 .duration_since(mtime)
2277 .unwrap_or(Duration::ZERO);
2278 assert!(
2279 age < Duration::from_mins(1),
2280 "seeded sidecar mtime should be near `now()`, got age {age:?}",
2281 );
2282 cleanup_reusable_worktree(&repo, &worktree_path);
2283 }
2284
2285 #[test]
2286 fn reusable_cache_gc_reclaims_prunable_orphan_when_dir_missing() {
2287 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2288 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
2289 let worktree_path = make_reusable_path("gc-orphan");
2290 register_reusable_worktree(&repo, &worktree_path);
2291 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
2294 let sidecar = reusable_worktree_last_used_path(&worktree_path);
2295
2296 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
2299 assert!(
2300 !worktree_path.exists(),
2301 "test pre-condition: cache dir should be gone",
2302 );
2303 assert!(
2304 worktree_admin_entry_present(&repo, &worktree_path),
2305 "test pre-condition: git admin entry should still be registered (prunable)",
2306 );
2307 assert!(
2308 sidecar.exists(),
2309 "test pre-condition: sidecar survives a dir-only reaper",
2310 );
2311
2312 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2313
2314 assert!(
2315 !worktree_admin_entry_present(&repo, &worktree_path),
2316 "sweep should unregister a prunable orphan whose dir was externally removed",
2317 );
2318 assert!(
2319 !sidecar.exists(),
2320 "sweep should remove the stale sidecar for a reclaimed orphan",
2321 );
2322 cleanup_reusable_worktree(&repo, &worktree_path);
2323 }
2324
2325 #[test]
2326 fn reusable_cache_gc_reclaims_prunable_orphan_even_when_age_gc_disabled() {
2327 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2328 let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
2329 let worktree_path = make_reusable_path("gc-orphan-nogc");
2330 register_reusable_worktree(&repo, &worktree_path);
2331 write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
2332 let sidecar = reusable_worktree_last_used_path(&worktree_path);
2333 fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
2334 assert!(
2335 worktree_admin_entry_present(&repo, &worktree_path),
2336 "test pre-condition: git admin entry should still be registered (prunable)",
2337 );
2338 assert!(
2339 sidecar.exists(),
2340 "test pre-condition: sidecar survives a dir-only reaper",
2341 );
2342
2343 sweep_old_reusable_caches(&repo, None, true);
2346
2347 assert!(
2348 !worktree_admin_entry_present(&repo, &worktree_path),
2349 "orphan reclaim must run even when age-based GC is disabled",
2350 );
2351 assert!(
2352 !sidecar.exists(),
2353 "sweep should remove the stale sidecar even when age-based GC is disabled",
2354 );
2355 cleanup_reusable_worktree(&repo, &worktree_path);
2356 }
2357
2358 #[test]
2359 fn reusable_cache_gc_preserves_lock_file_after_removal() {
2360 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2361 let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
2362 let worktree_path = make_reusable_path("gc-lockfile");
2363 register_reusable_worktree(&repo, &worktree_path);
2364 write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2365 let lock_path = reusable_worktree_lock_path(&worktree_path);
2366 drop(
2367 ReusableWorktreeLock::try_acquire(&worktree_path)
2368 .expect("test should acquire the lock"),
2369 );
2370 assert!(
2371 lock_path.exists(),
2372 "test pre-condition: lock file should exist before sweep",
2373 );
2374
2375 sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2376
2377 assert!(
2378 !worktree_path.exists(),
2379 "sweep should still remove the worktree directory",
2380 );
2381 assert!(
2382 lock_path.exists(),
2383 "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
2384 );
2385 let _ = fs::remove_file(&lock_path);
2386 cleanup_reusable_worktree(&repo, &worktree_path);
2387 }
2388
2389 #[test]
2390 fn reuse_or_create_stamps_sidecar_on_fresh_create() {
2391 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2392 let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
2393 let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2394
2395 let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
2396 .expect("fresh reuse_or_create should succeed on a clean repo");
2397 let cache_path = worktree.path().to_path_buf();
2398 let sidecar = reusable_worktree_last_used_path(&cache_path);
2399
2400 assert!(
2401 sidecar.exists(),
2402 "fresh-create must write the sidecar so age is measured from now",
2403 );
2404 let initial_age = std::fs::metadata(&sidecar)
2405 .and_then(|m| m.modified())
2406 .ok()
2407 .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
2408 .expect("sidecar mtime should be readable and not in the future");
2409 assert!(
2410 initial_age < Duration::from_mins(1),
2411 "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
2412 );
2413
2414 drop(worktree);
2415 cleanup_reusable_worktree(&repo, &cache_path);
2416 }
2417
2418 #[test]
2419 fn days_to_duration_zero_disables() {
2420 assert!(days_to_duration(0).is_none());
2421 assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
2422 assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
2423 }
2424
2425 #[test]
2426 fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
2427 let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
2428 let sidecar = reusable_worktree_last_used_path(&cache_dir);
2429 assert_eq!(sidecar.parent(), cache_dir.parent());
2430 assert_eq!(
2431 sidecar.file_name().and_then(|s| s.to_str()),
2432 Some("fallow-audit-base-cache-abcd-1234.last-used"),
2433 );
2434 }
2435
2436 #[test]
2437 fn touch_last_used_creates_sidecar_if_missing() {
2438 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2439 let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
2440 fs::create_dir(&cache_dir).expect("cache dir should be created");
2441 let sidecar = reusable_worktree_last_used_path(&cache_dir);
2442 assert!(!sidecar.exists(), "sidecar should not exist before touch");
2443
2444 touch_last_used(&cache_dir);
2445
2446 assert!(sidecar.exists(), "touch should create the sidecar");
2447 let mtime = fs::metadata(&sidecar)
2448 .and_then(|m| m.modified())
2449 .expect("sidecar should have an mtime");
2450 let age = SystemTime::now()
2451 .duration_since(mtime)
2452 .unwrap_or(Duration::ZERO);
2453 assert!(
2454 age < Duration::from_mins(1),
2455 "touched sidecar should be near `now()`",
2456 );
2457 }
2458
2459 #[test]
2460 fn reusable_worktree_lock_excludes_concurrent_acquires() {
2461 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2462 let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
2463 let lock_path = reusable_worktree_lock_path(&reusable);
2464
2465 let first = ReusableWorktreeLock::try_acquire(&reusable)
2466 .expect("first acquire on a fresh path should succeed");
2467 assert!(
2468 ReusableWorktreeLock::try_acquire(&reusable).is_none(),
2469 "second acquire must fail while the first is held",
2470 );
2471 drop(first);
2472 assert!(
2473 lock_path.exists(),
2474 "lock file must persist after drop (only the kernel lock is released)",
2475 );
2476 }
2477
2478 #[test]
2479 fn base_analysis_root_preserves_repo_subdirectory_roots() {
2480 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2481 let repo = tmp.path().join("repo");
2482 let app_root = repo.join("apps/mobile");
2483 let base_worktree = tmp.path().join("base-worktree");
2484 fs::create_dir_all(&app_root).expect("app root should be created");
2485 fs::create_dir_all(&base_worktree).expect("base worktree should be created");
2486 git(&repo, &["init", "-b", "main"]);
2487
2488 assert_eq!(
2489 base_analysis_root(&app_root, &base_worktree),
2490 base_worktree.join("apps/mobile")
2491 );
2492 }
2493
2494 #[test]
2495 fn audit_base_worktree_reuses_current_node_modules_context() {
2496 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2497 let root = tmp.path();
2498 fs::create_dir_all(root.join("src")).expect("src dir should be created");
2499 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2500 .expect("gitignore should be written");
2501 fs::write(
2502 root.join("package.json"),
2503 r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
2504 )
2505 .expect("package.json should be written");
2506 fs::write(
2507 root.join("tsconfig.json"),
2508 r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
2509 )
2510 .expect("tsconfig should be written");
2511 fs::write(
2512 root.join("src/index.ts"),
2513 "import { used } from '@/feature';\nconsole.log(used);\n",
2514 )
2515 .expect("index should be written");
2516 fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
2517 .expect("feature should be written");
2518
2519 git(root, &["init", "-b", "main"]);
2520 git(root, &["add", "."]);
2521 git(
2522 root,
2523 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2524 );
2525
2526 let rn_config = root.join("node_modules/@react-native/typescript-config");
2527 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2528 fs::write(
2529 rn_config.join("tsconfig.json"),
2530 r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
2531 )
2532 .expect("node_modules tsconfig should be written");
2533
2534 let worktree =
2535 BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
2536 assert!(
2537 worktree.path().join("node_modules").is_dir(),
2538 "base worktree should reuse ignored node_modules from the current checkout"
2539 );
2540 assert!(
2541 worktree
2542 .path()
2543 .join("node_modules/@react-native/typescript-config/tsconfig.json")
2544 .is_file(),
2545 "base worktree should preserve tsconfig extends targets installed in node_modules"
2546 );
2547 }
2548
2549 #[test]
2559 fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
2560 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2561 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2562
2563 let dot_nuxt = host.path().join(".nuxt");
2564 fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
2565 fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
2566 .expect(".nuxt/tsconfig.json should be written");
2567 fs::write(
2568 dot_nuxt.join("tsconfig.app.json"),
2569 r#"{"compilerOptions":{}}"#,
2570 )
2571 .expect(".nuxt/tsconfig.app.json should be written");
2572
2573 materialize_base_dependency_context(host.path(), worktree.path());
2574
2575 let mirrored = worktree.path().join(".nuxt");
2576 assert!(
2577 mirrored.is_dir(),
2578 "base worktree should reuse the ignored .nuxt dir from the host checkout"
2579 );
2580 let link_meta = fs::symlink_metadata(&mirrored)
2581 .expect(".nuxt entry should exist as a symlink in the worktree");
2582 assert!(
2583 link_meta.file_type().is_symlink(),
2584 "base worktree's .nuxt should be a symlink to the host checkout"
2585 );
2586 assert!(
2587 mirrored.join("tsconfig.json").is_file(),
2588 "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
2589 prerequisite check stays quiet"
2590 );
2591 assert!(
2592 mirrored.join("tsconfig.app.json").is_file(),
2593 "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
2594 resolve without falling back to resolver-less resolution"
2595 );
2596 }
2597
2598 #[test]
2603 fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
2604 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2605 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2606
2607 let dot_astro = host.path().join(".astro");
2608 fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
2609 fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
2610 .expect(".astro/types.d.ts should be written");
2611
2612 materialize_base_dependency_context(host.path(), worktree.path());
2613
2614 let mirrored = worktree.path().join(".astro");
2615 assert!(
2616 mirrored.is_dir(),
2617 "base worktree should reuse the ignored .astro dir from the host checkout"
2618 );
2619 assert!(
2620 mirrored.join("types.d.ts").is_file(),
2621 "base worktree should expose generated Astro types so the Astro meta-framework \
2622 prerequisite check stays quiet"
2623 );
2624 }
2625
2626 #[test]
2633 fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
2634 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2635 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2636
2637 materialize_base_dependency_context(host.path(), worktree.path());
2638
2639 assert!(
2640 !worktree.path().join(".nuxt").exists(),
2641 "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
2642 );
2643 assert!(
2644 !worktree.path().join(".astro").exists(),
2645 "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
2646 );
2647 assert!(
2648 !worktree.path().join("node_modules").exists(),
2649 "base worktree should not fabricate a node_modules symlink when the host has none"
2650 );
2651 }
2652
2653 #[test]
2657 fn materialize_base_dependency_context_handles_each_dir_independently() {
2658 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2659 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2660
2661 fs::create_dir_all(host.path().join("node_modules"))
2662 .expect("host node_modules should be created");
2663
2664 materialize_base_dependency_context(host.path(), worktree.path());
2665
2666 assert!(
2667 worktree.path().join("node_modules").is_dir(),
2668 "node_modules should still be symlinked even when host has no .nuxt or .astro"
2669 );
2670 assert!(
2671 !worktree.path().join(".nuxt").exists(),
2672 "missing host .nuxt should leave the worktree slot empty"
2673 );
2674 }
2675
2676 #[test]
2683 fn materialize_base_dependency_context_preserves_real_worktree_dir() {
2684 let host = tempfile::TempDir::new().expect("host tempdir should be created");
2685 let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2686
2687 let host_nuxt = host.path().join(".nuxt");
2688 fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
2689 fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
2690 .expect("host .nuxt/tsconfig.json should be written");
2691
2692 let worktree_nuxt = worktree.path().join(".nuxt");
2693 fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
2694 fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
2695 .expect("worktree .nuxt/tsconfig.json should be written");
2696
2697 materialize_base_dependency_context(host.path(), worktree.path());
2698
2699 let link_meta = fs::symlink_metadata(&worktree_nuxt)
2700 .expect(".nuxt entry should still exist in the worktree");
2701 assert!(
2702 !link_meta.file_type().is_symlink(),
2703 "a real base-tracked .nuxt dir must not be replaced by a host symlink"
2704 );
2705 let contents =
2706 fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
2707 assert!(
2708 contents.contains("base"),
2709 "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
2710 );
2711 }
2712
2713 #[test]
2714 fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
2715 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2716 let root = tmp.path();
2717 fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2718 .expect("gitignore should be written");
2719 fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
2720 .expect("package.json should be written");
2721
2722 git(root, &["init", "-b", "main"]);
2723 git(root, &["add", "."]);
2724 git(
2725 root,
2726 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2727 );
2728
2729 let rn_config = root.join("node_modules/@react-native/typescript-config");
2730 fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2731 fs::write(rn_config.join("tsconfig.json"), "{}")
2732 .expect("node_modules tsconfig should be written");
2733
2734 let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
2735 let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2736 .expect("persistent base worktree should be created");
2737 let worktree_path = first.path().to_path_buf();
2738 assert!(
2739 worktree_path.join("node_modules").is_dir(),
2740 "initial persistent worktree should receive node_modules context"
2741 );
2742 remove_node_modules_context(&worktree_path);
2743 assert!(
2744 !worktree_path.join("node_modules").exists(),
2745 "test setup should remove the dependency context from the reusable worktree"
2746 );
2747 drop(first);
2748
2749 let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2750 .expect("ready persistent base worktree should be reused");
2751 assert_eq!(reused.path(), worktree_path.as_path());
2752 assert!(
2753 reused.path().join("node_modules").is_dir(),
2754 "ready persistent worktree should refresh missing node_modules context"
2755 );
2756
2757 remove_audit_worktree(root, reused.path());
2758 let _ = fs::remove_dir_all(reused.path());
2759 }
2760
2761 fn remove_node_modules_context(worktree_path: &Path) {
2762 let path = worktree_path.join("node_modules");
2763 let Ok(metadata) = fs::symlink_metadata(&path) else {
2764 return;
2765 };
2766 if metadata.file_type().is_symlink() {
2767 #[cfg(unix)]
2768 let _ = fs::remove_file(path);
2769 #[cfg(windows)]
2770 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
2771 } else {
2772 let _ = fs::remove_dir_all(path);
2773 }
2774 }
2775
2776 #[test]
2777 fn audit_base_snapshot_cache_payload_roundtrips_sets() {
2778 let key = AuditBaseSnapshotCacheKey {
2779 hash: 42,
2780 base_sha: "abc123".to_string(),
2781 };
2782 let snapshot = AuditKeySnapshot {
2783 dead_code: ["dead:a".to_string(), "dead:b".to_string()]
2784 .into_iter()
2785 .collect(),
2786 health: std::iter::once("health:a".to_string()).collect(),
2787 dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
2788 .into_iter()
2789 .collect(),
2790 };
2791
2792 let cached = cached_from_snapshot(&key, &snapshot);
2793 assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
2794 assert_eq!(cached.key_hash, key.hash);
2795 assert_eq!(cached.base_sha, key.base_sha);
2796 assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
2797
2798 let decoded = snapshot_from_cached(cached);
2799 assert_eq!(decoded.dead_code, snapshot.dead_code);
2800 assert_eq!(decoded.health, snapshot.health);
2801 assert_eq!(decoded.dupes, snapshot.dupes);
2802 }
2803
2804 #[test]
2805 fn audit_base_snapshot_cache_dir_writes_gitignore() {
2806 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2807 let cache_root = tmp.path().join(".custom-fallow-cache");
2808 let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2809
2810 ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2811
2812 assert_eq!(
2813 fs::read_to_string(cache_dir.join(".gitignore")).expect("gitignore should read"),
2814 "*\n"
2815 );
2816 }
2817
2818 #[test]
2819 fn audit_base_snapshot_cache_roundtrips_from_disk() {
2820 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2821 let config_path = None;
2822 let cache_root = tmp.path().join(".custom-fallow-cache");
2823 let opts = AuditOptions {
2824 root: tmp.path(),
2825 cache_dir: &cache_root,
2826 config_path: &config_path,
2827 output: OutputFormat::Json,
2828 no_cache: false,
2829 threads: 1,
2830 quiet: true,
2831 changed_since: Some("HEAD"),
2832 production: false,
2833 production_dead_code: None,
2834 production_health: None,
2835 production_dupes: None,
2836 workspace: None,
2837 changed_workspaces: None,
2838 explain: false,
2839 explain_skipped: false,
2840 performance: false,
2841 group_by: None,
2842 dead_code_baseline: None,
2843 health_baseline: None,
2844 dupes_baseline: None,
2845 max_crap: None,
2846 coverage: None,
2847 coverage_root: None,
2848 gate: AuditGate::NewOnly,
2849 include_entry_exports: false,
2850 runtime_coverage: None,
2851 min_invocations_hot: 100,
2852 };
2853 let key = AuditBaseSnapshotCacheKey {
2854 hash: 0xfeed,
2855 base_sha: "abc123".to_string(),
2856 };
2857 let snapshot = AuditKeySnapshot {
2858 dead_code: std::iter::once("dead:a".to_string()).collect(),
2859 health: std::iter::once("health:a".to_string()).collect(),
2860 dupes: std::iter::once("dupe:a".to_string()).collect(),
2861 };
2862
2863 save_cached_base_snapshot(&opts, &key, &snapshot);
2864 assert!(
2865 audit_base_snapshot_cache_file(&cache_root, &key).exists(),
2866 "snapshot should be saved below the configured cache directory"
2867 );
2868 let loaded = load_cached_base_snapshot(&opts, &key).expect("snapshot should load");
2869
2870 assert_eq!(loaded.dead_code, snapshot.dead_code);
2871 assert_eq!(loaded.health, snapshot.health);
2872 assert_eq!(loaded.dupes, snapshot.dupes);
2873 }
2874
2875 #[test]
2876 fn audit_base_snapshot_cache_rejects_mismatched_key() {
2877 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2878 let config_path = None;
2879 let cache_root = tmp.path().join(".custom-fallow-cache");
2880 let opts = AuditOptions {
2881 root: tmp.path(),
2882 cache_dir: &cache_root,
2883 config_path: &config_path,
2884 output: OutputFormat::Json,
2885 no_cache: false,
2886 threads: 1,
2887 quiet: true,
2888 changed_since: Some("HEAD"),
2889 production: false,
2890 production_dead_code: None,
2891 production_health: None,
2892 production_dupes: None,
2893 workspace: None,
2894 changed_workspaces: None,
2895 explain: false,
2896 explain_skipped: false,
2897 performance: false,
2898 group_by: None,
2899 dead_code_baseline: None,
2900 health_baseline: None,
2901 dupes_baseline: None,
2902 max_crap: None,
2903 coverage: None,
2904 coverage_root: None,
2905 gate: AuditGate::NewOnly,
2906 include_entry_exports: false,
2907 runtime_coverage: None,
2908 min_invocations_hot: 100,
2909 };
2910 let key = AuditBaseSnapshotCacheKey {
2911 hash: 0xbeef,
2912 base_sha: "head".to_string(),
2913 };
2914 let cached = CachedAuditKeySnapshot {
2915 version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
2916 cli_version: env!("CARGO_PKG_VERSION").to_string(),
2917 key_hash: key.hash,
2918 base_sha: "other".to_string(),
2919 dead_code: vec!["dead:a".to_string()],
2920 health: vec![],
2921 dupes: vec![],
2922 };
2923 let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2924 ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2925 fs::write(
2926 audit_base_snapshot_cache_file(&cache_root, &key),
2927 bitcode::encode(&cached),
2928 )
2929 .expect("cache file should be written");
2930
2931 assert!(load_cached_base_snapshot(&opts, &key).is_none());
2932 }
2933
2934 #[test]
2935 fn audit_base_snapshot_cache_key_includes_extended_config() {
2936 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2937 let root = tmp.path();
2938 fs::write(
2939 root.join(".fallowrc.json"),
2940 r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
2941 )
2942 .expect("config should be written");
2943 fs::write(
2944 root.join("base.json"),
2945 r#"{"rules":{"unused-exports":"off"}}"#,
2946 )
2947 .expect("base config should be written");
2948
2949 let config_path = None;
2950 let cache_root = root.join(".fallow");
2951 let opts = AuditOptions {
2952 root,
2953 cache_dir: &cache_root,
2954 config_path: &config_path,
2955 output: OutputFormat::Json,
2956 no_cache: false,
2957 threads: 1,
2958 quiet: true,
2959 changed_since: Some("HEAD"),
2960 production: false,
2961 production_dead_code: None,
2962 production_health: None,
2963 production_dupes: None,
2964 workspace: None,
2965 changed_workspaces: None,
2966 explain: false,
2967 explain_skipped: false,
2968 performance: false,
2969 group_by: None,
2970 dead_code_baseline: None,
2971 health_baseline: None,
2972 dupes_baseline: None,
2973 max_crap: None,
2974 coverage: None,
2975 coverage_root: None,
2976 gate: AuditGate::NewOnly,
2977 include_entry_exports: false,
2978 runtime_coverage: None,
2979 min_invocations_hot: 100,
2980 };
2981
2982 let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
2983 fs::write(
2984 root.join("base.json"),
2985 r#"{"rules":{"unused-exports":"error"}}"#,
2986 )
2987 .expect("base config should be updated");
2988 let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
2989
2990 assert_ne!(
2991 first["resolved_hash"], second["resolved_hash"],
2992 "extended config changes must invalidate cached base snapshots"
2993 );
2994 }
2995
2996 #[test]
2997 fn audit_gate_all_skips_base_snapshot() {
2998 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2999 let root = tmp.path();
3000 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3001 fs::write(
3002 root.join("package.json"),
3003 r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
3004 )
3005 .expect("package.json should be written");
3006 fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
3007 .expect("index should be written");
3008
3009 git(root, &["init", "-b", "main"]);
3010 git(root, &["add", "."]);
3011 git(
3012 root,
3013 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3014 );
3015 fs::write(
3016 root.join("src/index.ts"),
3017 "export const legacy = 1;\nexport const changed = 2;\n",
3018 )
3019 .expect("changed module should be written");
3020
3021 let config_path = None;
3022 let cache_root = root.join(".fallow");
3023 let opts = AuditOptions {
3024 root,
3025 cache_dir: &cache_root,
3026 config_path: &config_path,
3027 output: OutputFormat::Json,
3028 no_cache: true,
3029 threads: 1,
3030 quiet: true,
3031 changed_since: Some("HEAD"),
3032 production: false,
3033 production_dead_code: None,
3034 production_health: None,
3035 production_dupes: None,
3036 workspace: None,
3037 changed_workspaces: None,
3038 explain: false,
3039 explain_skipped: false,
3040 performance: false,
3041 group_by: None,
3042 dead_code_baseline: None,
3043 health_baseline: None,
3044 dupes_baseline: None,
3045 max_crap: None,
3046 coverage: None,
3047 coverage_root: None,
3048 gate: AuditGate::All,
3049 include_entry_exports: false,
3050 runtime_coverage: None,
3051 min_invocations_hot: 100,
3052 };
3053
3054 let result = execute_audit(&opts).expect("audit should execute");
3055 assert!(result.base_snapshot.is_none());
3056 assert_eq!(result.attribution.gate, AuditGate::All);
3057 assert_eq!(result.attribution.dead_code_introduced, 0);
3058 assert_eq!(result.attribution.dead_code_inherited, 0);
3059 }
3060
3061 #[test]
3062 fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
3063 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3064 let root = tmp.path();
3065 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3066 fs::write(
3067 root.join("package.json"),
3068 r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
3069 )
3070 .expect("package.json should be written");
3071 fs::write(
3072 root.join(".fallowrc.json"),
3073 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
3074 )
3075 .expect("config should be written");
3076 let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
3077 fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
3078 fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
3079 fs::write(root.join("README.md"), "before\n").expect("readme should be written");
3080
3081 git(root, &["init", "-b", "main"]);
3082 git(root, &["add", "."]);
3083 git(
3084 root,
3085 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3086 );
3087 fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
3088 fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
3089 .expect("cache dir should be created");
3090 fs::write(
3091 root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
3092 b"cache",
3093 )
3094 .expect("cache artifact should be written");
3095
3096 let before_worktrees = audit_worktree_names(root);
3097
3098 let config_path = None;
3099 let cache_root = root.join(".fallow");
3100 let opts = AuditOptions {
3101 root,
3102 cache_dir: &cache_root,
3103 config_path: &config_path,
3104 output: OutputFormat::Json,
3105 no_cache: true,
3106 threads: 1,
3107 quiet: true,
3108 changed_since: Some("HEAD"),
3109 production: false,
3110 production_dead_code: None,
3111 production_health: None,
3112 production_dupes: None,
3113 workspace: None,
3114 changed_workspaces: None,
3115 explain: false,
3116 explain_skipped: false,
3117 performance: true,
3118 group_by: None,
3119 dead_code_baseline: None,
3120 health_baseline: None,
3121 dupes_baseline: None,
3122 max_crap: None,
3123 coverage: None,
3124 coverage_root: None,
3125 gate: AuditGate::NewOnly,
3126 include_entry_exports: false,
3127 runtime_coverage: None,
3128 min_invocations_hot: 100,
3129 };
3130
3131 let result = execute_audit(&opts).expect("audit should execute");
3132 assert_eq!(result.verdict, AuditVerdict::Pass);
3133 assert_eq!(result.changed_files_count, 2);
3134 assert!(result.base_snapshot_skipped);
3135 assert!(result.base_snapshot.is_some());
3136
3137 let after_worktrees = audit_worktree_names(root);
3138 assert_eq!(
3139 before_worktrees, after_worktrees,
3140 "base snapshot skip must not create a temporary base worktree"
3141 );
3142 }
3143
3144 fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
3145 let mut names: Vec<String> = list_audit_worktrees(repo_root)
3146 .unwrap_or_default()
3147 .into_iter()
3148 .filter_map(|path| {
3149 path.file_name()
3150 .and_then(|name| name.to_str())
3151 .map(str::to_owned)
3152 })
3153 .collect();
3154 names.sort();
3155 names
3156 }
3157
3158 #[test]
3159 fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
3160 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3161 let root = tmp.path();
3162 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3163 fs::write(
3164 root.join("package.json"),
3165 r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
3166 )
3167 .expect("package.json should be written");
3168 fs::write(
3169 root.join("src/index.ts"),
3170 "import { used } from './used';\nused();\n",
3171 )
3172 .expect("index should be written");
3173 fs::write(
3174 root.join("src/used.ts"),
3175 "export function used() {\n return 1;\n}\n",
3176 )
3177 .expect("used module should be written");
3178
3179 git(root, &["init", "-b", "main"]);
3180 git(root, &["add", "."]);
3181 git(
3182 root,
3183 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3184 );
3185 fs::write(
3186 root.join("src/used.ts"),
3187 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
3188 )
3189 .expect("changed module should be written");
3190
3191 let config_path = None;
3192 let cache_root = root.join(".fallow");
3193 let opts = AuditOptions {
3194 root,
3195 cache_dir: &cache_root,
3196 config_path: &config_path,
3197 output: OutputFormat::Json,
3198 no_cache: true,
3199 threads: 1,
3200 quiet: true,
3201 changed_since: Some("HEAD"),
3202 production: false,
3203 production_dead_code: None,
3204 production_health: None,
3205 production_dupes: None,
3206 workspace: None,
3207 changed_workspaces: None,
3208 explain: false,
3209 explain_skipped: false,
3210 performance: true,
3211 group_by: None,
3212 dead_code_baseline: None,
3213 health_baseline: None,
3214 dupes_baseline: None,
3215 max_crap: None,
3216 coverage: None,
3217 coverage_root: None,
3218 gate: AuditGate::NewOnly,
3219 include_entry_exports: false,
3220 runtime_coverage: None,
3221 min_invocations_hot: 100,
3222 };
3223
3224 let result = execute_audit(&opts).expect("audit should execute");
3225 let health = result.health.expect("health should run for changed files");
3226 let timings = health.timings.expect("performance timings should be kept");
3227 assert!(timings.discover_ms.abs() < f64::EPSILON);
3228 assert!(timings.parse_ms.abs() < f64::EPSILON);
3229 assert!(
3230 result.dupes.is_some(),
3231 "dupes should run when changed files exist"
3232 );
3233 }
3234
3235 #[test]
3236 fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
3237 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3238 let root = tmp.path();
3239 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3240 fs::write(
3241 root.join("package.json"),
3242 r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
3243 )
3244 .expect("package.json should be written");
3245 fs::write(
3246 root.join("src/index.ts"),
3247 "import { used } from './used';\nused();\n",
3248 )
3249 .expect("index should be written");
3250 fs::write(
3251 root.join("src/used.ts"),
3252 "export function used() {\n return 1;\n}\n",
3253 )
3254 .expect("used module should be written");
3255
3256 git(root, &["init", "-b", "main"]);
3257 git(root, &["add", "."]);
3258 git(
3259 root,
3260 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3261 );
3262 fs::write(
3263 root.join("src/used.ts"),
3264 "export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
3265 )
3266 .expect("changed module should be written");
3267
3268 let config_path = None;
3269 let cache_root = root.join(".fallow");
3270 let opts = AuditOptions {
3271 root,
3272 cache_dir: &cache_root,
3273 config_path: &config_path,
3274 output: OutputFormat::Json,
3275 no_cache: true,
3276 threads: 1,
3277 quiet: true,
3278 changed_since: Some("HEAD"),
3279 production: false,
3280 production_dead_code: Some(true),
3281 production_health: Some(false),
3282 production_dupes: Some(false),
3283 workspace: None,
3284 changed_workspaces: None,
3285 explain: false,
3286 explain_skipped: false,
3287 performance: true,
3288 group_by: None,
3289 dead_code_baseline: None,
3290 health_baseline: None,
3291 dupes_baseline: None,
3292 max_crap: None,
3293 coverage: None,
3294 coverage_root: None,
3295 gate: AuditGate::NewOnly,
3296 include_entry_exports: false,
3297 runtime_coverage: None,
3298 min_invocations_hot: 100,
3299 };
3300
3301 let result = execute_audit(&opts).expect("audit should execute");
3302 assert!(result.dupes.is_some(), "dupes should still run");
3303 }
3304
3305 #[cfg(unix)]
3306 #[test]
3307 fn remap_focus_files_does_not_canonicalize_through_symlinks() {
3308 let tmp = tempfile::TempDir::new().expect("temp dir");
3309 let real = tmp.path().join("real");
3310 let link = tmp.path().join("link");
3311 fs::create_dir_all(&real).expect("real dir");
3312 std::os::unix::fs::symlink(&real, &link).expect("symlink");
3313 let canonical = link.canonicalize().expect("canonicalize symlink");
3314 assert_ne!(link, canonical, "symlink should not equal its target");
3315
3316 let from_root = PathBuf::from("/repo");
3317 let mut focus = FxHashSet::default();
3318 focus.insert(from_root.join("src/foo.ts"));
3319
3320 let remapped = remap_focus_files(&focus, &from_root, &link)
3321 .expect("remap should succeed for in-prefix files");
3322
3323 let expected = link.join("src/foo.ts");
3324 assert!(
3325 remapped.contains(&expected),
3326 "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
3327 );
3328 }
3329
3330 #[test]
3331 fn remap_focus_files_skips_paths_outside_from_root() {
3332 let from_root = PathBuf::from("/repo/apps/web");
3333 let to_root = PathBuf::from("/wt/apps/web");
3334 let mut focus = FxHashSet::default();
3335 focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
3336 focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
3337
3338 let remapped =
3339 remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
3340
3341 assert_eq!(remapped.len(), 1);
3342 assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
3343 }
3344
3345 #[test]
3346 fn remap_focus_files_returns_none_when_no_paths_map() {
3347 let from_root = PathBuf::from("/repo/apps/web");
3348 let to_root = PathBuf::from("/wt/apps/web");
3349 let mut focus = FxHashSet::default();
3350 focus.insert(PathBuf::from("/elsewhere/foo.ts"));
3351
3352 let remapped = remap_focus_files(&focus, &from_root, &to_root);
3353 assert!(
3354 remapped.is_none(),
3355 "remap should return None when no paths can be mapped, falling caller back to full corpus"
3356 );
3357 }
3358
3359 #[test]
3360 fn remap_cache_dir_moves_project_local_cache_to_base_worktree() {
3361 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3362 let current_root = tmp.path().join("repo");
3363 let base_root = tmp.path().join("fallow-base");
3364 let cache_dir = current_root.join(".cache").join("fallow");
3365
3366 let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
3367
3368 assert_eq!(remapped, base_root.join(".cache").join("fallow"));
3369 }
3370
3371 #[test]
3372 fn remap_cache_dir_keeps_external_absolute_cache_shared() {
3373 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3374 let current_root = tmp.path().join("repo");
3375 let base_root = tmp.path().join("fallow-base");
3376 let cache_dir = tmp.path().join("shared").join("fallow-cache");
3377
3378 let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
3379
3380 assert_eq!(remapped, cache_dir);
3381 }
3382
3383 #[test]
3384 fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
3385 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3386 let root_buf = tmp
3387 .path()
3388 .canonicalize()
3389 .expect("temp root should canonicalize");
3390 let root = root_buf.as_path();
3391 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3392 fs::write(
3393 root.join("package.json"),
3394 r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
3395 )
3396 .expect("package.json should be written");
3397 fs::write(
3398 root.join(".fallowrc.json"),
3399 r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
3400 )
3401 .expect("config should be written");
3402
3403 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";
3404 fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
3405 fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
3406
3407 git(root, &["init", "-b", "main"]);
3408 git(root, &["add", "."]);
3409 git(
3410 root,
3411 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3412 );
3413 fs::write(
3414 root.join("src/changed.ts"),
3415 format!("{dup_block}// touched\n"),
3416 )
3417 .expect("changed file should be modified");
3418 git(root, &["add", "."]);
3419 git(
3420 root,
3421 &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
3422 );
3423
3424 let config_path = None;
3425 let cache_root = root.join(".fallow");
3426 let opts = AuditOptions {
3427 root,
3428 cache_dir: &cache_root,
3429 config_path: &config_path,
3430 output: OutputFormat::Json,
3431 no_cache: true,
3432 threads: 1,
3433 quiet: true,
3434 changed_since: Some("HEAD~1"),
3435 production: false,
3436 production_dead_code: None,
3437 production_health: None,
3438 production_dupes: None,
3439 workspace: None,
3440 changed_workspaces: None,
3441 explain: false,
3442 explain_skipped: false,
3443 performance: false,
3444 group_by: None,
3445 dead_code_baseline: None,
3446 health_baseline: None,
3447 dupes_baseline: None,
3448 max_crap: None,
3449 coverage: None,
3450 coverage_root: None,
3451 gate: AuditGate::NewOnly,
3452 include_entry_exports: false,
3453 runtime_coverage: None,
3454 min_invocations_hot: 100,
3455 };
3456
3457 let result = execute_audit(&opts).expect("audit should execute");
3458 assert!(
3459 result.base_snapshot_skipped,
3460 "comment-only JS/TS diffs should reuse current keys as the base snapshot"
3461 );
3462 let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
3463 assert!(
3464 !dupes_report.clone_groups.is_empty(),
3465 "current run should detect the pre-existing duplicate"
3466 );
3467 assert_eq!(
3468 result.attribution.duplication_introduced, 0,
3469 "pre-existing duplicate must not be classified as introduced; \
3470 attribution = {:?}",
3471 result.attribution
3472 );
3473 assert!(
3474 result.attribution.duplication_inherited > 0,
3475 "pre-existing duplicate must be classified as inherited; \
3476 attribution = {:?}",
3477 result.attribution
3478 );
3479 }
3480
3481 #[test]
3482 fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
3483 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3484 let root = tmp.path();
3485 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3486 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3487 .expect("node_modules config dir should be created");
3488 fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
3489 fs::write(
3490 root.join("package.json"),
3491 r#"{
3492 "name": "audit-react-native-tsconfig-base",
3493 "private": true,
3494 "main": "src/App.tsx",
3495 "dependencies": {
3496 "react-native": "0.80.0"
3497 }
3498 }"#,
3499 )
3500 .expect("package.json should be written");
3501 fs::write(
3502 root.join("tsconfig.json"),
3503 r#"{
3504 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3505 "compilerOptions": {
3506 "baseUrl": ".",
3507 "paths": {
3508 "@/*": ["src/*"]
3509 }
3510 },
3511 "include": ["src/**/*"]
3512 }"#,
3513 )
3514 .expect("tsconfig should be written");
3515 fs::write(
3516 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3517 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3518 )
3519 .expect("react native tsconfig should be written");
3520 fs::write(
3521 root.join("src/App.tsx"),
3522 r#"import { homeTitle } from "@/screens/Home";
3523
3524export function App() {
3525 return homeTitle;
3526}
3527"#,
3528 )
3529 .expect("app should be written");
3530 fs::write(
3531 root.join("src/screens/Home.ts"),
3532 r#"export const homeTitle = "home";
3533"#,
3534 )
3535 .expect("home should be written");
3536
3537 git(root, &["init", "-b", "main"]);
3538 git(root, &["add", "."]);
3539 git(
3540 root,
3541 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3542 );
3543 fs::write(
3544 root.join("src/App.tsx"),
3545 r#"import { homeTitle } from "@/screens/Home";
3546
3547export function App() {
3548 return homeTitle.toUpperCase();
3549}
3550"#,
3551 )
3552 .expect("app should be modified");
3553
3554 let config_path = None;
3555 let cache_root = root.join(".fallow");
3556 let opts = AuditOptions {
3557 root,
3558 cache_dir: &cache_root,
3559 config_path: &config_path,
3560 output: OutputFormat::Json,
3561 no_cache: true,
3562 threads: 1,
3563 quiet: true,
3564 changed_since: Some("HEAD"),
3565 production: false,
3566 production_dead_code: None,
3567 production_health: None,
3568 production_dupes: None,
3569 workspace: None,
3570 changed_workspaces: None,
3571 explain: false,
3572 explain_skipped: false,
3573 performance: false,
3574 group_by: None,
3575 dead_code_baseline: None,
3576 health_baseline: None,
3577 dupes_baseline: None,
3578 max_crap: None,
3579 coverage: None,
3580 coverage_root: None,
3581 gate: AuditGate::NewOnly,
3582 include_entry_exports: false,
3583 runtime_coverage: None,
3584 min_invocations_hot: 100,
3585 };
3586
3587 let result = execute_audit(&opts).expect("audit should execute");
3588 assert!(
3589 !result.base_snapshot_skipped,
3590 "source diffs should run a real base snapshot"
3591 );
3592 let base = result
3593 .base_snapshot
3594 .as_ref()
3595 .expect("base snapshot should run");
3596 assert!(
3597 !base
3598 .dead_code
3599 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3600 "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
3601 base.dead_code
3602 );
3603 assert!(
3604 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3605 "alias target should stay reachable in the base worktree: {:?}",
3606 base.dead_code
3607 );
3608 let check = result.check.as_ref().expect("dead-code audit should run");
3609 assert!(
3610 check.results.unresolved_imports.is_empty(),
3611 "HEAD audit should also resolve @/* aliases: {:?}",
3612 check.results.unresolved_imports
3613 );
3614 }
3615
3616 #[test]
3617 fn audit_base_preserves_subdirectory_root_resolution() {
3618 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3619 let repo = tmp.path().join("repo");
3620 let root = repo.join("apps/mobile");
3621 fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3622 fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3623 .expect("node_modules config dir should be created");
3624 fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
3625 .expect("gitignore should be written");
3626 fs::write(
3627 root.join("package.json"),
3628 r#"{
3629 "name": "audit-subdir-react-native-tsconfig-base",
3630 "private": true,
3631 "main": "src/App.tsx",
3632 "dependencies": {
3633 "react-native": "0.80.0"
3634 }
3635 }"#,
3636 )
3637 .expect("package.json should be written");
3638 fs::write(
3639 root.join("tsconfig.json"),
3640 r#"{
3641 "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3642 "compilerOptions": {
3643 "baseUrl": ".",
3644 "paths": {
3645 "@/*": ["src/*"]
3646 }
3647 },
3648 "include": ["src/**/*"]
3649 }"#,
3650 )
3651 .expect("tsconfig should be written");
3652 fs::write(
3653 root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3654 r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3655 )
3656 .expect("react native tsconfig should be written");
3657 fs::write(
3658 root.join("src/App.tsx"),
3659 r#"import { homeTitle } from "@/screens/Home";
3660
3661export function App() {
3662 return homeTitle;
3663}
3664"#,
3665 )
3666 .expect("app should be written");
3667 fs::write(
3668 root.join("src/screens/Home.ts"),
3669 r#"export const homeTitle = "home";
3670"#,
3671 )
3672 .expect("home should be written");
3673
3674 git(&repo, &["init", "-b", "main"]);
3675 git(&repo, &["add", "."]);
3676 git(
3677 &repo,
3678 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3679 );
3680 fs::write(
3681 root.join("src/App.tsx"),
3682 r#"import { homeTitle } from "@/screens/Home";
3683
3684export function App() {
3685 return homeTitle.toUpperCase();
3686}
3687"#,
3688 )
3689 .expect("app should be modified");
3690
3691 let config_path = None;
3692 let cache_root = root.join(".fallow");
3693 let opts = AuditOptions {
3694 root: &root,
3695 cache_dir: &cache_root,
3696 config_path: &config_path,
3697 output: OutputFormat::Json,
3698 no_cache: true,
3699 threads: 1,
3700 quiet: true,
3701 changed_since: Some("HEAD"),
3702 production: false,
3703 production_dead_code: None,
3704 production_health: None,
3705 production_dupes: None,
3706 workspace: None,
3707 changed_workspaces: None,
3708 explain: false,
3709 explain_skipped: false,
3710 performance: false,
3711 group_by: None,
3712 dead_code_baseline: None,
3713 health_baseline: None,
3714 dupes_baseline: None,
3715 max_crap: None,
3716 coverage: None,
3717 coverage_root: None,
3718 gate: AuditGate::NewOnly,
3719 include_entry_exports: false,
3720 runtime_coverage: None,
3721 min_invocations_hot: 100,
3722 };
3723
3724 let result = execute_audit(&opts).expect("audit should execute");
3725 assert!(
3726 !result.base_snapshot_skipped,
3727 "source diffs should run a real base snapshot"
3728 );
3729 let base = result
3730 .base_snapshot
3731 .as_ref()
3732 .expect("base snapshot should run");
3733 assert!(
3734 !base
3735 .dead_code
3736 .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3737 "base audit should analyze from the app subdirectory, not the repo root: {:?}",
3738 base.dead_code
3739 );
3740 assert!(
3741 !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3742 "subdirectory base audit should keep alias targets reachable: {:?}",
3743 base.dead_code
3744 );
3745 }
3746
3747 #[test]
3748 fn audit_base_uses_new_explicit_config_without_hard_failure() {
3749 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3750 let root = tmp.path();
3751 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3752 fs::write(
3753 root.join("package.json"),
3754 r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
3755 )
3756 .expect("package.json should be written");
3757 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3758 .expect("index should be written");
3759
3760 git(root, &["init", "-b", "main"]);
3761 git(root, &["add", "."]);
3762 git(
3763 root,
3764 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3765 );
3766
3767 let explicit_config = root.join(".fallowrc.json");
3768 fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
3769 .expect("new config should be written");
3770 fs::write(root.join("src/index.ts"), "export const used = 2;\n")
3771 .expect("index should be modified");
3772
3773 let config_path = Some(explicit_config);
3774 let cache_root = root.join(".fallow");
3775 let opts = AuditOptions {
3776 root,
3777 cache_dir: &cache_root,
3778 config_path: &config_path,
3779 output: OutputFormat::Json,
3780 no_cache: true,
3781 threads: 1,
3782 quiet: true,
3783 changed_since: Some("HEAD"),
3784 production: false,
3785 production_dead_code: None,
3786 production_health: None,
3787 production_dupes: None,
3788 workspace: None,
3789 changed_workspaces: None,
3790 explain: false,
3791 explain_skipped: false,
3792 performance: false,
3793 group_by: None,
3794 dead_code_baseline: None,
3795 health_baseline: None,
3796 dupes_baseline: None,
3797 max_crap: None,
3798 coverage: None,
3799 coverage_root: None,
3800 gate: AuditGate::NewOnly,
3801 include_entry_exports: false,
3802 runtime_coverage: None,
3803 min_invocations_hot: 100,
3804 };
3805
3806 let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
3807 assert!(
3808 result.base_snapshot.is_some(),
3809 "base snapshot should use the current explicit config even when the base commit lacks it"
3810 );
3811 }
3812
3813 #[test]
3814 fn audit_base_uses_current_discovered_config_for_attribution() {
3815 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3816 let root = tmp.path();
3817 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3818 fs::write(
3819 root.join("package.json"),
3820 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3821 )
3822 .expect("package.json should be written");
3823 fs::write(
3824 root.join(".fallowrc.json"),
3825 r#"{"rules":{"unused-dependencies":"off"}}"#,
3826 )
3827 .expect("base config should be written");
3828 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3829 .expect("index should be written");
3830
3831 git(root, &["init", "-b", "main"]);
3832 git(root, &["add", "."]);
3833 git(
3834 root,
3835 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3836 );
3837
3838 fs::write(
3839 root.join(".fallowrc.json"),
3840 r#"{"rules":{"unused-dependencies":"error"}}"#,
3841 )
3842 .expect("current config should be written");
3843 fs::write(
3844 root.join("package.json"),
3845 r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3846 )
3847 .expect("package.json should be touched");
3848
3849 let config_path = None;
3850 let cache_root = root.join(".fallow");
3851 let opts = AuditOptions {
3852 root,
3853 cache_dir: &cache_root,
3854 config_path: &config_path,
3855 output: OutputFormat::Json,
3856 no_cache: true,
3857 threads: 1,
3858 quiet: true,
3859 changed_since: Some("HEAD"),
3860 production: false,
3861 production_dead_code: None,
3862 production_health: None,
3863 production_dupes: None,
3864 workspace: None,
3865 changed_workspaces: None,
3866 explain: false,
3867 explain_skipped: false,
3868 performance: false,
3869 group_by: None,
3870 dead_code_baseline: None,
3871 health_baseline: None,
3872 dupes_baseline: None,
3873 max_crap: None,
3874 coverage: None,
3875 coverage_root: None,
3876 gate: AuditGate::NewOnly,
3877 include_entry_exports: false,
3878 runtime_coverage: None,
3879 min_invocations_hot: 100,
3880 };
3881
3882 let result = execute_audit(&opts).expect("audit should execute");
3883 assert_eq!(
3884 result.attribution.dead_code_introduced, 0,
3885 "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
3886 result.attribution
3887 );
3888 assert!(
3889 result.attribution.dead_code_inherited > 0,
3890 "pre-existing changed-file findings should be classified as inherited: {:?}",
3891 result.attribution
3892 );
3893 }
3894
3895 #[test]
3896 fn audit_base_current_config_attribution_survives_cache_hit() {
3897 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3898 let root = tmp.path();
3899 fs::create_dir_all(root.join("src")).expect("src dir should be created");
3900 fs::write(
3901 root.join("package.json"),
3902 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3903 )
3904 .expect("package.json should be written");
3905 fs::write(
3906 root.join(".fallowrc.json"),
3907 r#"{"rules":{"unused-dependencies":"off"}}"#,
3908 )
3909 .expect("base config should be written");
3910 fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3911 .expect("index should be written");
3912
3913 git(root, &["init", "-b", "main"]);
3914 git(root, &["add", "."]);
3915 git(
3916 root,
3917 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3918 );
3919
3920 fs::write(
3921 root.join(".fallowrc.json"),
3922 r#"{"rules":{"unused-dependencies":"error"}}"#,
3923 )
3924 .expect("current config should be written");
3925 fs::write(
3926 root.join("package.json"),
3927 r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3928 )
3929 .expect("package.json should be touched");
3930
3931 let config_path = None;
3932 let cache_root = root.join(".fallow");
3933 let opts = AuditOptions {
3934 root,
3935 cache_dir: &cache_root,
3936 config_path: &config_path,
3937 output: OutputFormat::Json,
3938 no_cache: false,
3939 threads: 1,
3940 quiet: true,
3941 changed_since: Some("HEAD"),
3942 production: false,
3943 production_dead_code: None,
3944 production_health: None,
3945 production_dupes: None,
3946 workspace: None,
3947 changed_workspaces: None,
3948 explain: false,
3949 explain_skipped: false,
3950 performance: false,
3951 group_by: None,
3952 dead_code_baseline: None,
3953 health_baseline: None,
3954 dupes_baseline: None,
3955 max_crap: None,
3956 coverage: None,
3957 coverage_root: None,
3958 gate: AuditGate::NewOnly,
3959 include_entry_exports: false,
3960 runtime_coverage: None,
3961 min_invocations_hot: 100,
3962 };
3963
3964 let first = execute_audit(&opts).expect("first audit should execute");
3965 assert_eq!(
3966 first.attribution.dead_code_introduced, 0,
3967 "first audit should classify pre-existing findings as inherited: {:?}",
3968 first.attribution
3969 );
3970
3971 let changed_files =
3972 crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
3973 let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
3974 .expect("cache key should compute")
3975 .expect("cache key should exist");
3976 assert!(
3977 load_cached_base_snapshot(&opts, &key).is_some(),
3978 "first audit should store a reusable base snapshot"
3979 );
3980
3981 let second = execute_audit(&opts).expect("second audit should execute");
3982 assert_eq!(
3983 second.attribution.dead_code_introduced, 0,
3984 "cache hit should keep current-config attribution stable: {:?}",
3985 second.attribution
3986 );
3987 assert!(
3988 second.attribution.dead_code_inherited > 0,
3989 "cache hit should preserve inherited base findings: {:?}",
3990 second.attribution
3991 );
3992 }
3993
3994 #[test]
3995 fn audit_dupes_only_materializes_groups_touching_changed_files() {
3996 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3997 let root_path = tmp
3998 .path()
3999 .canonicalize()
4000 .expect("temp root should canonicalize");
4001 let root = root_path.as_path();
4002 fs::create_dir_all(root.join("src")).expect("src dir should be created");
4003 fs::write(
4004 root.join("package.json"),
4005 r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
4006 )
4007 .expect("package.json should be written");
4008 fs::write(
4009 root.join(".fallowrc.json"),
4010 r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
4011 )
4012 .expect("config should be written");
4013
4014 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";
4015 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";
4016 fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
4017 fs::write(root.join("src/focused-copy.ts"), focused_code)
4018 .expect("focused copy should be written");
4019 fs::write(root.join("src/untouched-a.ts"), untouched_code)
4020 .expect("untouched a should be written");
4021 fs::write(root.join("src/untouched-b.ts"), untouched_code)
4022 .expect("untouched b should be written");
4023
4024 git(root, &["init", "-b", "main"]);
4025 git(root, &["add", "."]);
4026 git(
4027 root,
4028 &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4029 );
4030 fs::write(
4031 root.join("src/changed.ts"),
4032 format!("{focused_code}export const changedMarker = true;\n"),
4033 )
4034 .expect("changed file should be modified");
4035
4036 let config_path = None;
4037 let cache_root = root.join(".fallow");
4038 let opts = AuditOptions {
4039 root,
4040 cache_dir: &cache_root,
4041 config_path: &config_path,
4042 output: OutputFormat::Json,
4043 no_cache: true,
4044 threads: 1,
4045 quiet: true,
4046 changed_since: Some("HEAD"),
4047 production: false,
4048 production_dead_code: None,
4049 production_health: None,
4050 production_dupes: None,
4051 workspace: None,
4052 changed_workspaces: None,
4053 explain: false,
4054 explain_skipped: false,
4055 performance: false,
4056 group_by: None,
4057 dead_code_baseline: None,
4058 health_baseline: None,
4059 dupes_baseline: None,
4060 max_crap: None,
4061 coverage: None,
4062 coverage_root: None,
4063 gate: AuditGate::All,
4064 include_entry_exports: false,
4065 runtime_coverage: None,
4066 min_invocations_hot: 100,
4067 };
4068
4069 let result = execute_audit(&opts).expect("audit should execute");
4070 let dupes = result.dupes.expect("dupes should run");
4071 let changed_path = root.join("src/changed.ts");
4072
4073 assert!(
4074 !dupes.report.clone_groups.is_empty(),
4075 "changed file should still match unchanged duplicate code"
4076 );
4077 assert!(dupes.report.clone_groups.iter().all(|group| {
4078 group
4079 .instances
4080 .iter()
4081 .any(|instance| instance.file == changed_path)
4082 }));
4083 }
4084
4085 #[test]
4088 fn tokens_equivalent_whitespace_only() {
4089 let a = "export const x = 1;\nexport const y = 2;\n";
4091 let b = "export const x = 1;\n\n\nexport const y = 2;\n";
4092 assert!(
4093 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4094 "whitespace-only change must be treated as equivalent"
4095 );
4096 }
4097
4098 #[test]
4099 fn tokens_equivalent_comment_only_change() {
4100 let a = "export const x = 1;\n";
4103 let b = "// note\nexport const x = 1;\n";
4104 assert!(
4105 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4106 "comment-only change must be treated as equivalent (comments emit no tokens)"
4107 );
4108 }
4109
4110 #[test]
4111 fn tokens_equivalent_identifier_rename_is_not_equivalent() {
4112 let a = "export const a = 1;\n";
4114 let b = "export const b = 1;\n";
4115 assert!(
4116 !js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4117 "identifier rename must be treated as non-equivalent"
4118 );
4119 }
4120
4121 #[test]
4122 fn tokens_equivalent_string_literal_change_is_not_equivalent() {
4123 let a = r#"import x from "./a";"#;
4125 let b = r#"import x from "./b";"#;
4126 assert!(
4127 !js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4128 "string-literal change must be treated as non-equivalent"
4129 );
4130 }
4131
4132 #[test]
4133 fn tokens_equivalent_fallow_ignore_marker_forces_false() {
4134 let code = "// fallow-ignore-next-line unused-exports\nexport const x = 1;\n";
4137 assert!(
4138 !js_ts_tokens_equivalent(Path::new("a.ts"), code, code),
4139 "fallow-ignore marker in either side must force false"
4140 );
4141 }
4142
4143 #[test]
4144 fn tokens_equivalent_non_js_extension_is_false() {
4145 let a = ".foo { color: red; }\n";
4147 let b = ".foo {\n color: red;\n}\n";
4148 assert!(
4149 !js_ts_tokens_equivalent(Path::new("styles.css"), a, b),
4150 "non-JS/TS extension must always return false"
4151 );
4152 }
4153
4154 #[test]
4163 fn tokens_equivalent_template_literal_content_change_is_equivalent_known_gap() {
4164 let a = "const p = import(`./pages/${x}`);\n";
4165 let b = "const p = import(`./views/${x}`);\n";
4166 assert!(
4171 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4172 "template-literal content change is CURRENTLY treated as equivalent (known gap)"
4173 );
4174 }
4175
4176 #[test]
4179 fn tokens_equivalent_regex_literal_content_change_is_equivalent_known_gap() {
4180 let a = "const re = /^foo/;\n";
4181 let b = "const re = /^bar/;\n";
4182 assert!(
4184 js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
4185 "regex-literal content change is CURRENTLY treated as equivalent (known gap)"
4186 );
4187 }
4188
4189 #[test]
4190 fn analysis_input_and_doc_classification() {
4191 assert!(is_analysis_input(Path::new("src/app.ts")));
4193 assert!(is_analysis_input(Path::new("src/app.tsx")));
4194 assert!(is_analysis_input(Path::new("src/app.js")));
4195 assert!(is_analysis_input(Path::new("src/app.jsx")));
4196 assert!(is_analysis_input(Path::new("src/app.mts")));
4197 assert!(is_analysis_input(Path::new("src/app.vue")));
4198 assert!(is_analysis_input(Path::new("src/styles.css")));
4199
4200 assert!(!is_analysis_input(Path::new("README.md")));
4202 assert!(!is_analysis_input(Path::new("package.json")));
4203 assert!(!is_analysis_input(Path::new("image.png")));
4204
4205 assert!(is_non_behavioral_doc(Path::new("README.md")));
4207 assert!(is_non_behavioral_doc(Path::new("CHANGELOG.txt")));
4208 assert!(is_non_behavioral_doc(Path::new("docs/guide.rst")));
4209 assert!(is_non_behavioral_doc(Path::new("docs/guide.adoc")));
4210
4211 assert!(!is_analysis_input(Path::new("package.json")));
4214 assert!(!is_non_behavioral_doc(Path::new("package.json")));
4215 }
4216}