1use std::path::{Path, PathBuf};
17use std::process::Output;
18use std::sync::OnceLock;
19
20use rustc_hash::{FxHashMap, FxHashSet};
21
22use crate::duplicates::{DuplicationReport, DuplicationStats, families};
23use crate::results::AnalysisResults;
24
25pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
32
33static SPAWN_HOOK: OnceLock<ChangedFilesSpawnHook> = OnceLock::new();
34
35pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
42 let _ = SPAWN_HOOK.set(hook);
43}
44
45fn spawn_output(command: &mut std::process::Command) -> std::io::Result<Output> {
46 if let Some(hook) = SPAWN_HOOK.get() {
47 hook(command)
48 } else {
49 command.output()
50 }
51}
52
53pub fn validate_git_ref(s: &str) -> Result<&str, String> {
66 if s.is_empty() {
67 return Err("git ref cannot be empty".to_string());
68 }
69 if s.starts_with('-') {
70 return Err("git ref cannot start with '-'".to_string());
71 }
72 let mut in_braces = false;
73 for c in s.chars() {
74 match c {
75 '{' => in_braces = true,
76 '}' => in_braces = false,
77 ':' | ' ' if in_braces => {}
78 c if c.is_ascii_alphanumeric()
79 || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
80 _ => return Err(format!("git ref contains disallowed character: '{c}'")),
81 }
82 }
83 if in_braces {
84 return Err("git ref has unclosed '{'".to_string());
85 }
86 Ok(s)
87}
88
89#[derive(Debug)]
92pub enum ChangedFilesError {
93 InvalidRef(String),
95 GitMissing(String),
97 NotARepository,
99 GitFailed(String),
101}
102
103impl ChangedFilesError {
104 pub fn describe(&self) -> String {
108 match self {
109 Self::InvalidRef(e) => format!("invalid git ref: {e}"),
110 Self::GitMissing(e) => format!("failed to run git: {e}"),
111 Self::NotARepository => "not a git repository".to_owned(),
112 Self::GitFailed(stderr) => augment_git_failed(stderr),
113 }
114 }
115}
116
117fn augment_git_failed(stderr: &str) -> String {
123 let lower = stderr.to_ascii_lowercase();
124 if lower.contains("not a valid object name")
125 || lower.contains("unknown revision")
126 || lower.contains("ambiguous argument")
127 {
128 format!(
129 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
130 )
131 } else {
132 stderr.to_owned()
133 }
134}
135
136pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
147 let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
148 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
149
150 if !output.status.success() {
151 let stderr = String::from_utf8_lossy(&output.stderr);
152 return Err(if stderr.contains("not a git repository") {
153 ChangedFilesError::NotARepository
154 } else {
155 ChangedFilesError::GitFailed(stderr.trim().to_owned())
156 });
157 }
158
159 let raw = String::from_utf8_lossy(&output.stdout);
160 let trimmed = raw.trim();
161 if trimmed.is_empty() {
162 return Err(ChangedFilesError::GitFailed(
163 "git rev-parse --show-toplevel returned empty output".to_owned(),
164 ));
165 }
166
167 let path = PathBuf::from(trimmed);
168 Ok(dunce::canonicalize(&path).unwrap_or(path))
169}
170
171pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
185 let output = spawn_output(&mut git_command(
186 cwd,
187 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
188 ))
189 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
190
191 if !output.status.success() {
192 let stderr = String::from_utf8_lossy(&output.stderr);
193 return Err(if stderr.contains("not a git repository") {
194 ChangedFilesError::NotARepository
195 } else {
196 ChangedFilesError::GitFailed(stderr.trim().to_owned())
197 });
198 }
199
200 let raw = String::from_utf8_lossy(&output.stdout);
201 let trimmed = raw.trim();
202 if trimmed.is_empty() {
203 return Err(ChangedFilesError::GitFailed(
204 "git rev-parse --git-common-dir returned empty output".to_owned(),
205 ));
206 }
207
208 let path = PathBuf::from(trimmed);
209 Ok(dunce::canonicalize(&path).unwrap_or(path))
210}
211
212fn collect_git_paths(
213 cwd: &Path,
214 toplevel: &Path,
215 args: &[&str],
216) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
217 let output = spawn_output(&mut git_command(cwd, args))
218 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
219
220 if !output.status.success() {
221 let stderr = String::from_utf8_lossy(&output.stderr);
222 return Err(if stderr.contains("not a git repository") {
223 ChangedFilesError::NotARepository
224 } else {
225 ChangedFilesError::GitFailed(stderr.trim().to_owned())
226 });
227 }
228
229 #[cfg(windows)]
230 let normalise_segment = |line: &str| line.replace('/', "\\");
231 #[cfg(not(windows))]
232 let normalise_segment = |line: &str| line.to_owned();
233
234 let files: FxHashSet<PathBuf> = String::from_utf8_lossy(&output.stdout)
235 .lines()
236 .filter(|line| !line.is_empty())
237 .map(|line| toplevel.join(normalise_segment(line)))
238 .collect();
239
240 Ok(files)
241}
242
243fn git_command(cwd: &Path, args: &[&str]) -> std::process::Command {
244 let mut command = crate::spawn::git();
245 command.args(args).current_dir(cwd);
246 command
247}
248
249pub fn try_get_changed_files(
267 root: &Path,
268 git_ref: &str,
269) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
270 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
271 let toplevel = resolve_git_toplevel(root)?;
272 try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
273}
274
275pub fn try_get_changed_files_with_toplevel(
283 cwd: &Path,
284 toplevel: &Path,
285 git_ref: &str,
286) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
287 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
288
289 let mut files = collect_git_paths(
290 cwd,
291 toplevel,
292 &[
293 "diff",
294 "--name-only",
295 "--end-of-options",
296 &format!("{git_ref}...HEAD"),
297 ],
298 )?;
299 files.extend(collect_git_paths(
300 cwd,
301 toplevel,
302 &["diff", "--name-only", "HEAD"],
303 )?);
304 files.extend(collect_git_paths(
305 cwd,
306 toplevel,
307 &["ls-files", "--full-name", "--others", "--exclude-standard"],
308 )?);
309 Ok(files)
310}
311
312pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
328 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
329 let output = spawn_output(&mut git_command(
330 root,
331 &[
332 "diff",
333 "--relative",
334 "--unified=0",
335 "--end-of-options",
336 &format!("{git_ref}...HEAD"),
337 ],
338 ))
339 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
340
341 if !output.status.success() {
342 let stderr = String::from_utf8_lossy(&output.stderr);
343 return Err(if stderr.contains("not a git repository") {
344 ChangedFilesError::NotARepository
345 } else {
346 ChangedFilesError::GitFailed(stderr.trim().to_owned())
347 });
348 }
349
350 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
351}
352
353#[expect(
357 clippy::print_stderr,
358 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; LSP callers use try_get_changed_files instead"
359)]
360pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
361 match try_get_changed_files(root, git_ref) {
362 Ok(files) => Some(files),
363 Err(ChangedFilesError::InvalidRef(e)) => {
364 eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
365 None
366 }
367 Err(ChangedFilesError::GitMissing(e)) => {
368 eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
369 None
370 }
371 Err(ChangedFilesError::NotARepository) => {
372 eprintln!("Warning: --changed-since ignored: not a git repository");
373 None
374 }
375 Err(ChangedFilesError::GitFailed(stderr)) => {
376 eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
377 None
378 }
379 }
380}
381
382#[expect(
395 clippy::implicit_hasher,
396 reason = "fallow standardizes on FxHashSet across the workspace"
397)]
398pub fn filter_results_by_changed_files(
399 results: &mut AnalysisResults,
400 changed_files: &FxHashSet<PathBuf>,
401) {
402 let AnalysisResults {
403 unused_files,
404 unused_exports,
405 unused_types,
406 private_type_leaks,
407 unused_dependencies: _unused_dependencies,
410 unused_dev_dependencies: _unused_dev_dependencies,
411 unused_optional_dependencies: _unused_optional_dependencies,
412 unused_enum_members,
413 unused_class_members,
414 unresolved_imports,
415 unlisted_dependencies,
416 duplicate_exports,
417 type_only_dependencies: _type_only_dependencies,
420 test_only_dependencies: _test_only_dependencies,
421 circular_dependencies,
422 re_export_cycles,
423 boundary_violations,
424 boundary_coverage_violations,
425 boundary_call_violations,
426 policy_violations,
427 stale_suppressions,
428 unused_catalog_entries: _unused_catalog_entries,
431 empty_catalog_groups,
432 unresolved_catalog_references,
433 unused_dependency_overrides,
434 misconfigured_dependency_overrides,
435 suppression_count: _suppression_count,
437 active_suppressions: _active_suppressions,
438 feature_flags: _feature_flags,
439 security_findings,
440 security_unresolved_edge_files: _security_unresolved_edge_files,
441 security_unresolved_callee_sites: _security_unresolved_callee_sites,
442 security_unresolved_callee_diagnostics,
443 export_usages: _export_usages,
446 entry_point_summary: _entry_point_summary,
447 } = &mut *results;
448
449 let cf = normalize_changed_files_set(changed_files);
450 unused_files.retain(|f| contains_normalized(&cf, &f.file.path));
451 unused_exports.retain(|e| contains_normalized(&cf, &e.export.path));
452 unused_types.retain(|e| contains_normalized(&cf, &e.export.path));
453 private_type_leaks.retain(|e| contains_normalized(&cf, &e.leak.path));
454 unused_enum_members.retain(|m| contains_normalized(&cf, &m.member.path));
455 unused_class_members.retain(|m| contains_normalized(&cf, &m.member.path));
456 unresolved_imports.retain(|i| contains_normalized(&cf, &i.import.path));
457
458 unlisted_dependencies.retain(|d| {
459 d.dep
460 .imported_from
461 .iter()
462 .any(|s| contains_normalized(&cf, &s.path))
463 });
464
465 for dup in &mut *duplicate_exports {
466 dup.export
467 .locations
468 .retain(|loc| contains_normalized(&cf, &loc.path));
469 }
470 duplicate_exports.retain(|d| d.export.locations.len() >= 2);
471
472 circular_dependencies.retain(|c| c.cycle.files.iter().any(|f| contains_normalized(&cf, f)));
473
474 re_export_cycles.retain(|c| c.cycle.files.iter().any(|f| contains_normalized(&cf, f)));
475
476 boundary_violations.retain(|v| contains_normalized(&cf, &v.violation.from_path));
477 boundary_coverage_violations.retain(|v| contains_normalized(&cf, &v.violation.path));
478 boundary_call_violations.retain(|v| contains_normalized(&cf, &v.violation.path));
479 policy_violations.retain(|v| contains_normalized(&cf, &v.violation.path));
480
481 stale_suppressions.retain(|s| contains_normalized(&cf, &s.path));
482
483 security_findings.retain(|f| {
484 contains_normalized(&cf, &f.path)
485 || f.trace
486 .iter()
487 .any(|hop| contains_normalized(&cf, &hop.path))
488 || f.reachability.as_ref().is_some_and(|reachability| {
489 reachability
490 .untrusted_source_trace
491 .iter()
492 .any(|hop| contains_normalized(&cf, &hop.path))
493 })
494 });
495 security_unresolved_callee_diagnostics.retain(|d| contains_normalized(&cf, &d.path));
496
497 unresolved_catalog_references.retain(|r| contains_normalized(&cf, &r.reference.path));
498 empty_catalog_groups.retain(|g| normalized_set_contains_path(&cf, &g.group.path));
499
500 unused_dependency_overrides.retain(|o| contains_normalized(&cf, &o.entry.path));
501 misconfigured_dependency_overrides.retain(|o| contains_normalized(&cf, &o.entry.path));
502}
503
504fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
517 changed_files
518 .iter()
519 .map(|p| dunce::simplified(p).to_path_buf())
520 .collect()
521}
522
523fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
524 normalized.contains(dunce::simplified(path))
525}
526
527fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
528 contains_normalized(normalized, path)
529 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
530}
531
532fn recompute_duplication_stats(report: &DuplicationReport) -> DuplicationStats {
538 let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
539 let mut file_dup_lines: FxHashMap<&Path, FxHashSet<usize>> = FxHashMap::default();
540 let mut duplicated_tokens = 0_usize;
541 let mut clone_instances = 0_usize;
542
543 for group in &report.clone_groups {
544 for instance in &group.instances {
545 files_with_clones.insert(&instance.file);
546 clone_instances += 1;
547 let lines = file_dup_lines.entry(&instance.file).or_default();
548 for line in instance.start_line..=instance.end_line {
549 lines.insert(line);
550 }
551 }
552 duplicated_tokens += group.token_count * group.instances.len();
553 }
554
555 let duplicated_lines: usize = file_dup_lines.values().map(FxHashSet::len).sum();
556
557 DuplicationStats {
558 total_files: report.stats.total_files,
559 files_with_clones: files_with_clones.len(),
560 total_lines: report.stats.total_lines,
561 duplicated_lines,
562 total_tokens: report.stats.total_tokens,
563 duplicated_tokens,
564 clone_groups: report.clone_groups.len(),
565 clone_instances,
566 #[expect(
567 clippy::cast_precision_loss,
568 reason = "stat percentages are display-only; precision loss at usize::MAX line counts is acceptable"
569 )]
570 duplication_percentage: if report.stats.total_lines > 0 {
571 (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
572 } else {
573 0.0
574 },
575 clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
576 }
577}
578
579#[expect(
584 clippy::implicit_hasher,
585 reason = "fallow standardizes on FxHashSet across the workspace"
586)]
587pub fn filter_duplication_by_changed_files(
588 report: &mut DuplicationReport,
589 changed_files: &FxHashSet<PathBuf>,
590 root: &Path,
591) {
592 let cf = normalize_changed_files_set(changed_files);
593 report.clone_groups.retain(|g| {
594 g.instances
595 .iter()
596 .any(|i| contains_normalized(&cf, &i.file))
597 });
598 report.clone_families = families::group_into_families(&report.clone_groups, root);
599 report.mirrored_directories =
600 families::detect_mirrored_directories(&report.clone_families, root);
601 report.stats = recompute_duplication_stats(report);
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use crate::duplicates::{CloneGroup, CloneInstance};
608 use crate::results::{
609 BoundaryViolation, CircularDependency, EmptyCatalogGroup, SecurityFinding,
610 SecurityFindingKind, SecurityUnresolvedCalleeDiagnostic, TraceHop, TraceHopRole,
611 UnusedExport, UnusedFile,
612 };
613 use fallow_types::extract::{SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason};
614 use fallow_types::output_dead_code::{
615 BoundaryViolationFinding, CircularDependencyFinding, EmptyCatalogGroupFinding,
616 UnusedExportFinding, UnusedFileFinding,
617 };
618 use fallow_types::results::{SecurityReachability, SecuritySeverity};
619
620 #[test]
621 fn changed_files_error_describe_variants() {
622 assert!(
623 ChangedFilesError::InvalidRef("bad".to_owned())
624 .describe()
625 .contains("invalid git ref")
626 );
627 assert!(
628 ChangedFilesError::GitMissing("oops".to_owned())
629 .describe()
630 .contains("oops")
631 );
632 assert_eq!(
633 ChangedFilesError::NotARepository.describe(),
634 "not a git repository"
635 );
636 assert!(
637 ChangedFilesError::GitFailed("bad ref".to_owned())
638 .describe()
639 .contains("bad ref")
640 );
641 }
642
643 #[test]
644 fn augment_git_failed_appends_shallow_clone_hint_for_unknown_revision() {
645 let stderr = "fatal: ambiguous argument 'fallow-baseline...HEAD': unknown revision or path not in the working tree.";
646 let described = ChangedFilesError::GitFailed(stderr.to_owned()).describe();
647 assert!(described.contains(stderr), "original stderr preserved");
648 assert!(
649 described.contains("shallow clone"),
650 "hint surfaced: {described}"
651 );
652 assert!(
653 described.contains("fetch-depth: 0") || described.contains("git fetch --unshallow"),
654 "hint actionable: {described}"
655 );
656 }
657
658 #[test]
659 fn augment_git_failed_passthrough_for_other_errors() {
660 let stderr = "fatal: refusing to merge unrelated histories";
661 let described = ChangedFilesError::GitFailed(stderr.to_owned()).describe();
662 assert_eq!(described, stderr);
663 }
664
665 #[test]
666 fn validate_git_ref_rejects_leading_dash() {
667 assert!(validate_git_ref("--upload-pack=evil").is_err());
668 assert!(validate_git_ref("-flag").is_err());
669 }
670
671 #[test]
672 fn validate_git_ref_accepts_baseline_tag() {
673 assert_eq!(
674 validate_git_ref("fallow-baseline").unwrap(),
675 "fallow-baseline"
676 );
677 }
678
679 #[test]
680 fn changed_files_filter_scopes_unresolved_callee_diagnostics() {
681 let mut results = AnalysisResults::default();
682 results
683 .security_unresolved_callee_diagnostics
684 .push(SecurityUnresolvedCalleeDiagnostic {
685 path: PathBuf::from("/repo/src/changed.ts"),
686 line: 4,
687 col: 0,
688 reason: SkippedSecurityCalleeReason::DynamicDispatch,
689 expression_kind: SkippedSecurityCalleeExpressionKind::Other,
690 });
691 results
692 .security_unresolved_callee_diagnostics
693 .push(SecurityUnresolvedCalleeDiagnostic {
694 path: PathBuf::from("/repo/src/unchanged.ts"),
695 line: 4,
696 col: 0,
697 reason: SkippedSecurityCalleeReason::ComputedMember,
698 expression_kind: SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
699 });
700
701 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
702 changed.insert(PathBuf::from("/repo/src/changed.ts"));
703
704 filter_results_by_changed_files(&mut results, &changed);
705
706 assert_eq!(results.security_unresolved_callee_diagnostics.len(), 1);
707 assert_eq!(
708 results.security_unresolved_callee_diagnostics[0].path,
709 PathBuf::from("/repo/src/changed.ts")
710 );
711 }
712
713 #[test]
714 fn try_get_changed_files_rejects_invalid_ref() {
715 let err = try_get_changed_files(Path::new("/"), "--evil")
716 .expect_err("leading-dash ref must be rejected");
717 assert!(matches!(err, ChangedFilesError::InvalidRef(_)));
718 assert!(err.describe().contains("cannot start with"));
719 }
720
721 #[test]
722 fn validate_git_ref_rejects_option_like_ref() {
723 assert!(validate_git_ref("--output=/tmp/fallow-proof").is_err());
724 }
725
726 #[test]
727 fn validate_git_ref_allows_reflog_relative_date() {
728 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
729 }
730
731 #[test]
732 fn try_get_changed_files_rejects_option_like_ref_before_git() {
733 let root = tempfile::tempdir().expect("create temp dir");
734 let proof_path = root.path().join("proof");
735
736 let result = try_get_changed_files(
737 root.path(),
738 &format!("--output={}", proof_path.to_string_lossy()),
739 );
740
741 assert!(matches!(result, Err(ChangedFilesError::InvalidRef(_))));
742 assert!(
743 !proof_path.exists(),
744 "invalid changedSince ref must not be passed through to git as an option"
745 );
746 }
747
748 #[test]
749 fn git_command_clears_parent_git_environment() {
750 let command = git_command(Path::new("."), &["status", "--short"]);
751 let overrides: Vec<_> = command.get_envs().collect();
752
753 for var in crate::git_env::AMBIENT_GIT_ENV_VARS {
754 assert!(
755 overrides
756 .iter()
757 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
758 "git helper must clear inherited {var}",
759 );
760 }
761 }
762
763 #[test]
764 fn filter_results_keeps_only_changed_files() {
765 let mut results = AnalysisResults::default();
766 results
767 .unused_files
768 .push(UnusedFileFinding::with_actions(UnusedFile {
769 path: "/a.ts".into(),
770 }));
771 results
772 .unused_files
773 .push(UnusedFileFinding::with_actions(UnusedFile {
774 path: "/b.ts".into(),
775 }));
776 results
777 .unused_exports
778 .push(UnusedExportFinding::with_actions(UnusedExport {
779 path: "/a.ts".into(),
780 export_name: "foo".into(),
781 is_type_only: false,
782 line: 1,
783 col: 0,
784 span_start: 0,
785 is_re_export: false,
786 }));
787
788 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
789 changed.insert("/a.ts".into());
790
791 filter_results_by_changed_files(&mut results, &changed);
792
793 assert_eq!(results.unused_files.len(), 1);
794 assert_eq!(results.unused_files[0].file.path, PathBuf::from("/a.ts"));
795 assert_eq!(results.unused_exports.len(), 1);
796 }
797
798 #[test]
799 fn filter_results_preserves_dependency_level_issues() {
800 let mut results = AnalysisResults::default();
801 results.unused_dependencies.push(
802 fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
803 crate::results::UnusedDependency {
804 package_name: "lodash".into(),
805 location: crate::results::DependencyLocation::Dependencies,
806 path: "/pkg.json".into(),
807 line: 3,
808 used_in_workspaces: Vec::new(),
809 },
810 ),
811 );
812
813 let changed: FxHashSet<PathBuf> = FxHashSet::default();
814 filter_results_by_changed_files(&mut results, &changed);
815
816 assert_eq!(results.unused_dependencies.len(), 1);
817 }
818
819 #[test]
820 fn filter_results_keeps_circular_dep_when_any_file_changed() {
821 let mut results = AnalysisResults::default();
822 results
823 .circular_dependencies
824 .push(CircularDependencyFinding::with_actions(
825 CircularDependency {
826 files: vec!["/a.ts".into(), "/b.ts".into()],
827 length: 2,
828 line: 1,
829 col: 0,
830 edges: Vec::new(),
831 is_cross_package: false,
832 },
833 ));
834
835 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
836 changed.insert("/b.ts".into());
837
838 filter_results_by_changed_files(&mut results, &changed);
839 assert_eq!(results.circular_dependencies.len(), 1);
840 }
841
842 #[test]
843 fn filter_results_drops_circular_dep_when_no_file_changed() {
844 let mut results = AnalysisResults::default();
845 results
846 .circular_dependencies
847 .push(CircularDependencyFinding::with_actions(
848 CircularDependency {
849 files: vec!["/a.ts".into(), "/b.ts".into()],
850 length: 2,
851 line: 1,
852 col: 0,
853 edges: Vec::new(),
854 is_cross_package: false,
855 },
856 ));
857
858 let changed: FxHashSet<PathBuf> = FxHashSet::default();
859 filter_results_by_changed_files(&mut results, &changed);
860 assert!(results.circular_dependencies.is_empty());
861 }
862
863 #[test]
864 fn filter_results_drops_boundary_violation_when_importer_unchanged() {
865 let mut results = AnalysisResults::default();
866 results
867 .boundary_violations
868 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
869 from_path: "/a.ts".into(),
870 to_path: "/b.ts".into(),
871 from_zone: "ui".into(),
872 to_zone: "data".into(),
873 import_specifier: "../data/db".into(),
874 line: 1,
875 col: 0,
876 }));
877
878 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
879 changed.insert("/b.ts".into());
880
881 filter_results_by_changed_files(&mut results, &changed);
882 assert!(results.boundary_violations.is_empty());
883 }
884
885 #[test]
886 fn filter_results_keeps_security_finding_when_trace_file_changed() {
887 let mut results = AnalysisResults::default();
888 results.security_findings.push(SecurityFinding {
889 finding_id: String::new(),
890 candidate: fallow_types::results::SecurityCandidate::default(),
891 taint_flow: None,
892 attack_surface: None,
893 kind: SecurityFindingKind::ClientServerLeak,
894 category: None,
895 cwe: None,
896 path: "/project/src/client.tsx".into(),
897 line: 2,
898 col: 0,
899 evidence: "candidate".into(),
900 source_backed: false,
901 source_read: None,
902 severity: SecuritySeverity::Low,
903 trace: vec![
904 TraceHop {
905 path: "/project/src/client.tsx".into(),
906 line: 2,
907 col: 0,
908 role: TraceHopRole::ClientBoundary,
909 },
910 TraceHop {
911 path: "/project/src/server.ts".into(),
912 line: 1,
913 col: 0,
914 role: TraceHopRole::SecretSource,
915 },
916 ],
917 actions: Vec::new(),
918 dead_code: None,
919 reachability: None,
920 runtime: None,
921 });
922
923 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
924 changed.insert("/project/src/server.ts".into());
925
926 filter_results_by_changed_files(&mut results, &changed);
927
928 assert_eq!(results.security_findings.len(), 1);
929 }
930
931 #[test]
932 fn filter_results_keeps_security_finding_when_untrusted_source_trace_file_changed() {
933 let mut results = AnalysisResults::default();
934 results.security_findings.push(SecurityFinding {
935 finding_id: String::new(),
936 candidate: fallow_types::results::SecurityCandidate::default(),
937 taint_flow: None,
938 attack_surface: None,
939 kind: SecurityFindingKind::TaintedSink,
940 category: Some("command-injection".into()),
941 cwe: Some(78),
942 path: "/project/src/runner.ts".into(),
943 line: 4,
944 col: 2,
945 evidence: "candidate".into(),
946 source_backed: false,
947 source_read: None,
948 severity: SecuritySeverity::Low,
949 trace: Vec::new(),
950 actions: Vec::new(),
951 dead_code: None,
952 reachability: Some(SecurityReachability {
953 reachable_from_entry: false,
954 reachable_from_untrusted_source: true,
955 taint_confidence: Some(fallow_types::results::TaintConfidence::ModuleLevel),
956 untrusted_source_hop_count: Some(1),
957 untrusted_source_trace: vec![
958 TraceHop {
959 path: "/project/src/route.ts".into(),
960 line: 1,
961 col: 0,
962 role: TraceHopRole::UntrustedSource,
963 },
964 TraceHop {
965 path: "/project/src/runner.ts".into(),
966 line: 4,
967 col: 2,
968 role: TraceHopRole::Sink,
969 },
970 ],
971 blast_radius: 0,
972 crosses_boundary: false,
973 }),
974 runtime: None,
975 });
976
977 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
978 changed.insert("/project/src/route.ts".into());
979
980 filter_results_by_changed_files(&mut results, &changed);
981
982 assert_eq!(results.security_findings.len(), 1);
983 }
984
985 #[test]
986 fn filter_results_keeps_relative_empty_catalog_group_when_manifest_changed() {
987 let mut results = AnalysisResults::default();
988 results
989 .empty_catalog_groups
990 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
991 catalog_name: "legacy".into(),
992 path: PathBuf::from("pnpm-workspace.yaml"),
993 line: 4,
994 }));
995
996 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
997 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
998
999 filter_results_by_changed_files(&mut results, &changed);
1000
1001 assert_eq!(results.empty_catalog_groups.len(), 1);
1002 assert_eq!(results.empty_catalog_groups[0].group.catalog_name, "legacy");
1003 }
1004
1005 #[test]
1006 fn filter_duplication_keeps_groups_with_at_least_one_changed_instance() {
1007 let mut report = DuplicationReport {
1008 clone_groups: vec![CloneGroup {
1009 instances: vec![
1010 CloneInstance {
1011 file: "/a.ts".into(),
1012 start_line: 1,
1013 end_line: 5,
1014 start_col: 0,
1015 end_col: 10,
1016 fragment: "code".into(),
1017 },
1018 CloneInstance {
1019 file: "/b.ts".into(),
1020 start_line: 1,
1021 end_line: 5,
1022 start_col: 0,
1023 end_col: 10,
1024 fragment: "code".into(),
1025 },
1026 ],
1027 token_count: 20,
1028 line_count: 5,
1029 }],
1030 clone_families: vec![],
1031 mirrored_directories: vec![],
1032 stats: DuplicationStats {
1033 total_files: 2,
1034 files_with_clones: 2,
1035 total_lines: 100,
1036 duplicated_lines: 10,
1037 total_tokens: 200,
1038 duplicated_tokens: 40,
1039 clone_groups: 1,
1040 clone_instances: 2,
1041 duplication_percentage: 10.0,
1042 clone_groups_below_min_occurrences: 0,
1043 },
1044 };
1045
1046 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1047 changed.insert("/a.ts".into());
1048
1049 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1050 assert_eq!(report.clone_groups.len(), 1);
1051 assert_eq!(report.stats.clone_groups, 1);
1052 assert_eq!(report.stats.clone_instances, 2);
1053 }
1054
1055 #[cfg(windows)]
1063 #[test]
1064 fn filter_duplication_normalises_verbatim_prefix_mismatch() {
1065 let mut report = DuplicationReport {
1066 clone_groups: vec![CloneGroup {
1067 instances: vec![
1068 CloneInstance {
1069 file: PathBuf::from(r"\\?\C:\repo\src\changed.ts"),
1070 start_line: 1,
1071 end_line: 5,
1072 start_col: 0,
1073 end_col: 10,
1074 fragment: "code".into(),
1075 },
1076 CloneInstance {
1077 file: PathBuf::from(r"\\?\C:\repo\src\focused-copy.ts"),
1078 start_line: 1,
1079 end_line: 5,
1080 start_col: 0,
1081 end_col: 10,
1082 fragment: "code".into(),
1083 },
1084 ],
1085 token_count: 20,
1086 line_count: 5,
1087 }],
1088 clone_families: vec![],
1089 mirrored_directories: vec![],
1090 stats: DuplicationStats {
1091 total_files: 2,
1092 files_with_clones: 2,
1093 total_lines: 100,
1094 duplicated_lines: 10,
1095 total_tokens: 200,
1096 duplicated_tokens: 40,
1097 clone_groups: 1,
1098 clone_instances: 2,
1099 duplication_percentage: 10.0,
1100 clone_groups_below_min_occurrences: 0,
1101 },
1102 };
1103
1104 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1105 changed.insert(PathBuf::from(r"C:\repo\src\changed.ts"));
1106
1107 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1108 assert_eq!(
1109 report.clone_groups.len(),
1110 1,
1111 "verbatim instance path must match non-verbatim changed-file entry"
1112 );
1113 }
1114
1115 #[cfg(windows)]
1116 #[test]
1117 fn filter_results_normalises_verbatim_prefix_mismatch() {
1118 let mut results = AnalysisResults::default();
1119 results
1120 .unused_exports
1121 .push(UnusedExportFinding::with_actions(UnusedExport {
1122 path: PathBuf::from(r"\\?\C:\repo\src\a.ts"),
1123 export_name: "foo".into(),
1124 is_type_only: false,
1125 line: 1,
1126 col: 0,
1127 span_start: 0,
1128 is_re_export: false,
1129 }));
1130
1131 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1132 changed.insert(PathBuf::from(r"C:\repo\src\a.ts"));
1133
1134 filter_results_by_changed_files(&mut results, &changed);
1135 assert_eq!(
1136 results.unused_exports.len(),
1137 1,
1138 "verbatim finding path must match non-verbatim changed-file entry"
1139 );
1140 }
1141
1142 fn init_repo(repo: &Path) -> PathBuf {
1154 run_git(repo, &["init", "--quiet", "--initial-branch=main"]);
1155 run_git(repo, &["config", "user.email", "test@example.com"]);
1156 run_git(repo, &["config", "user.name", "test"]);
1157 run_git(repo, &["config", "commit.gpgsign", "false"]);
1158 std::fs::write(repo.join("seed.txt"), "seed\n").unwrap();
1159 run_git(repo, &["add", "seed.txt"]);
1160 run_git(repo, &["commit", "--quiet", "-m", "initial"]);
1161 run_git(repo, &["tag", "fallow-baseline"]);
1162 dunce::canonicalize(repo).unwrap()
1163 }
1164
1165 fn run_git(cwd: &Path, args: &[&str]) {
1166 let output = std::process::Command::new("git")
1167 .args(args)
1168 .current_dir(cwd)
1169 .output()
1170 .expect("git available");
1171 assert!(
1172 output.status.success(),
1173 "git {args:?} failed: {}",
1174 String::from_utf8_lossy(&output.stderr)
1175 );
1176 }
1177
1178 #[test]
1181 fn try_get_changed_files_workspace_at_repo_root() {
1182 let tmp = tempfile::tempdir().unwrap();
1183 let repo = init_repo(tmp.path());
1184 std::fs::create_dir_all(repo.join("src")).unwrap();
1185 std::fs::write(repo.join("src/new.ts"), "export const x = 1;\n").unwrap();
1186
1187 let changed = try_get_changed_files(&repo, "fallow-baseline").unwrap();
1188
1189 let expected = repo.join("src/new.ts");
1190 assert!(
1191 changed.contains(&expected),
1192 "changed set should contain {expected:?}; actual: {changed:?}"
1193 );
1194 }
1195
1196 #[test]
1204 fn try_get_changed_files_workspace_in_subdirectory() {
1205 let tmp = tempfile::tempdir().unwrap();
1206 let repo = init_repo(tmp.path());
1207 let frontend = repo.join("frontend");
1208 std::fs::create_dir_all(frontend.join("src")).unwrap();
1209 std::fs::write(frontend.join("src/new.ts"), "export const x = 1;\n").unwrap();
1210
1211 let changed = try_get_changed_files(&frontend, "fallow-baseline").unwrap();
1212
1213 let expected = repo.join("frontend/src/new.ts");
1214 assert!(
1215 changed.contains(&expected),
1216 "changed set should contain canonical {expected:?}; actual: {changed:?}"
1217 );
1218 let bogus = frontend.join("frontend/src/new.ts");
1219 assert!(
1220 !changed.contains(&bogus),
1221 "changed set must not contain double-frontend path {bogus:?}"
1222 );
1223 }
1224
1225 #[test]
1240 fn try_get_changed_files_includes_committed_sibling_changes() {
1241 let tmp = tempfile::tempdir().unwrap();
1242 let repo = init_repo(tmp.path());
1243 let backend = repo.join("backend");
1244 std::fs::create_dir_all(&backend).unwrap();
1245 std::fs::write(backend.join("server.py"), "print('hi')\n").unwrap();
1246 run_git(&repo, &["add", "."]);
1247 run_git(&repo, &["commit", "--quiet", "-m", "add backend"]);
1248
1249 let frontend = repo.join("frontend");
1250 std::fs::create_dir_all(&frontend).unwrap();
1251
1252 let changed = try_get_changed_files(&frontend, "fallow-baseline").unwrap();
1253
1254 let expected = repo.join("backend/server.py");
1255 assert!(
1256 changed.contains(&expected),
1257 "committed sibling backend/server.py should be in the set: {changed:?}"
1258 );
1259 }
1260
1261 #[test]
1265 fn try_get_changed_files_includes_modified_tracked_file() {
1266 let tmp = tempfile::tempdir().unwrap();
1267 let repo = init_repo(tmp.path());
1268 let frontend = repo.join("frontend");
1269 std::fs::create_dir_all(frontend.join("src")).unwrap();
1270 std::fs::write(frontend.join("src/old.ts"), "export const x = 1;\n").unwrap();
1271 run_git(&repo, &["add", "."]);
1272 run_git(&repo, &["commit", "--quiet", "-m", "add old"]);
1273 run_git(&repo, &["tag", "fallow-baseline-v2"]);
1274 std::fs::write(frontend.join("src/old.ts"), "export const x = 2;\n").unwrap();
1275
1276 let changed = try_get_changed_files(&frontend, "fallow-baseline-v2").unwrap();
1277
1278 let expected = repo.join("frontend/src/old.ts");
1279 assert!(
1280 changed.contains(&expected),
1281 "modified tracked file {expected:?} missing from set: {changed:?}"
1282 );
1283 }
1284
1285 #[test]
1291 fn resolve_git_toplevel_returns_canonical_path() {
1292 let tmp = tempfile::tempdir().unwrap();
1293 let repo = init_repo(tmp.path());
1294 let frontend = repo.join("frontend");
1295 std::fs::create_dir_all(&frontend).unwrap();
1296
1297 let toplevel = resolve_git_toplevel(&frontend).unwrap();
1298 assert_eq!(toplevel, repo, "toplevel should equal canonical repo root");
1299 assert_eq!(
1300 toplevel,
1301 dunce::canonicalize(&toplevel).unwrap(),
1302 "resolved toplevel should already be canonical"
1303 );
1304 }
1305
1306 #[test]
1310 fn resolve_git_toplevel_not_a_repository() {
1311 let tmp = tempfile::tempdir().unwrap();
1312 let result = resolve_git_toplevel(tmp.path());
1313 assert!(
1314 matches!(result, Err(ChangedFilesError::NotARepository)),
1315 "expected NotARepository, got {result:?}"
1316 );
1317 }
1318
1319 #[test]
1324 fn resolve_git_common_dir_collapses_worktrees() {
1325 let tmp = tempfile::tempdir().unwrap();
1326 let repo = init_repo(tmp.path());
1327 let linked = tmp.path().join("linked-worktree");
1328 run_git(
1329 &repo,
1330 &[
1331 "worktree",
1332 "add",
1333 "--quiet",
1334 linked.to_str().unwrap(),
1335 "-b",
1336 "feat",
1337 ],
1338 );
1339
1340 let main_common = resolve_git_common_dir(&repo).unwrap();
1341 let linked_common = resolve_git_common_dir(&linked).unwrap();
1342 assert_eq!(
1343 main_common, linked_common,
1344 "worktrees of one repo must share a common dir"
1345 );
1346
1347 let main_top = resolve_git_toplevel(&repo).unwrap();
1349 let linked_top = resolve_git_toplevel(&linked).unwrap();
1350 assert_ne!(
1351 main_top, linked_top,
1352 "the two worktrees should have distinct toplevels"
1353 );
1354 }
1355
1356 #[test]
1359 fn resolve_git_common_dir_not_a_repository() {
1360 let tmp = tempfile::tempdir().unwrap();
1361 let result = resolve_git_common_dir(tmp.path());
1362 assert!(
1363 matches!(result, Err(ChangedFilesError::NotARepository)),
1364 "expected NotARepository, got {result:?}"
1365 );
1366 }
1367
1368 #[test]
1371 fn try_get_changed_files_not_a_repository() {
1372 let tmp = tempfile::tempdir().unwrap();
1373 let result = try_get_changed_files(tmp.path(), "main");
1374 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1375 }
1376
1377 #[test]
1378 fn filter_duplication_drops_groups_with_no_changed_instance() {
1379 let mut report = DuplicationReport {
1380 clone_groups: vec![CloneGroup {
1381 instances: vec![CloneInstance {
1382 file: "/a.ts".into(),
1383 start_line: 1,
1384 end_line: 5,
1385 start_col: 0,
1386 end_col: 10,
1387 fragment: "code".into(),
1388 }],
1389 token_count: 20,
1390 line_count: 5,
1391 }],
1392 clone_families: vec![],
1393 mirrored_directories: vec![],
1394 stats: DuplicationStats {
1395 total_files: 1,
1396 files_with_clones: 1,
1397 total_lines: 100,
1398 duplicated_lines: 5,
1399 total_tokens: 100,
1400 duplicated_tokens: 20,
1401 clone_groups: 1,
1402 clone_instances: 1,
1403 duplication_percentage: 5.0,
1404 clone_groups_below_min_occurrences: 0,
1405 },
1406 };
1407
1408 let changed: FxHashSet<PathBuf> = FxHashSet::default();
1409 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1410 assert!(report.clone_groups.is_empty());
1411 assert_eq!(report.stats.clone_groups, 0);
1412 assert_eq!(report.stats.clone_instances, 0);
1413 assert!((report.stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
1414 }
1415}